Compare commits

...

10 commits

Author SHA1 Message Date
229cb9d9a8 update gitignore 2026-07-04 02:56:30 -03:00
99c16c52d4 rm .claude 2026-07-04 02:56:18 -03:00
c350767a7a fix(gateway): harden dispatch, session-boundary seq, and send-budget FIFO
- Tolerate op-0 frames with a non-binary `t` (e.g. `t: null`): the gateway
  no longer crashes the gen_statem via EventNames.to_atom/1's is_binary guard;
  such protocol noise is logged at debug, still bumps seq, and is not dispatched.
- Session.establish/2 resets last_seq to nil so a new session never inherits the
  previous session's sequence; READY's own frame reseeds it. Also stop bumping
  last_seq from frames replayed under reidentify_pending? (abandoned session).
- queue_or_send now follows queue-first FIFO discipline: a frame is only sent
  immediately when the queue is empty, otherwise it falls in behind existing
  queued frames and the front is drained, preventing newer op-3/op-8 frames from
  jumping older ones.
- op-3/op-8 casts outside :connected are enqueued (bounded) instead of dropped,
  so member-chunk requests issued during :resuming replay are not lost; they
  drain on the next :connected enter. Dropped only with no session context.
- Refund the token on a failed budgeted send (no more double-charge) and extract
  the shared send_one_budgeted/2 helper used by both the send-now and drain paths.
- Document the reidentify_pending? invariant at its declaration.
2026-07-04 02:17:16 -03:00
9ae463a91b fix(slash/dispatch/cache): contain registrar retries, route components/modals, guard global wipe, isolate cache crashes
- Registrar: restart: :temporary + bounded in-process retry (2s/10s, configurable)
  so a failing registration never cycles the gateway or shares the tree's
  max_restarts budget; loud error + normal exit on give-up.
- Global mode: skip the overwrite when commands/0 is empty (symmetric with guild
  mode) so a boot never wipes production commands; log how to wipe intentionally.
- Slash: route on top-level interaction type - 2 -> handle_interaction (by name),
  3 -> handle_component, 5 -> handle_modal (by custom_id). New optional callbacks
  with use-injected debug catch-alls; modules defining only handle_interaction
  still compile and work.
- Supervisor: nest Cache + Dispatcher under a :rest_for_one supervisor so a Cache
  crash recreates the ETS tables and restarts the Dispatcher after it, leaving
  the outer one_for_one Gateway/Session semantics untouched.
- Slash response helpers: merge a caller-supplied integer flags with the
  ephemeral bit via Bitwise.bor (4 -> 4; 4 + ephemeral -> 68).
2026-07-04 02:08:27 -03:00
04a43fd33c fix(api): unwedge abandoned probes and per-interaction rate-limit buckets
Probe abandonment wedge: a still-alive caller whose acquire_probe call
times out on a spent deadline was elected the probe server-side (reply
to a dead ref) but never settled and never died, wedging probing[route]
forever. Two layers: the caller now casts abandon_probe/1 on timeout
(server settles only if the entry's monitored pid matches, so a later
probe is never clobbered), and the server arms a force-settle timer at
election as a backstop against a lost abandon.

Interaction callbacks shared one route key: templatize collapsed the
interaction token to :id, so every callback shared one bucket and one
Remaining:0/429 stalled all acks toward the 3s deadline. Restore
per-interaction separation via the same non-reversible token digest used
for webhooks (id still collapses); secrets never stored raw.
2026-07-04 02:05:24 -03:00
1b0914d60e docs: write README, example bot, and ex_doc setup for Phase 6
Adds the full README (quickstart, interaction styles, privileged intents,
cache, REST, slash registration, event semantics, Nostrum porting guide,
reliability design), an examples/echo_bot.exs Mix.install script, ex_doc
config in mix.exs, and rewrites Dexcord.Gateway's moduledoc to describe
the finished reliability behavior instead of stale Phase 0-1 scope notes.
2026-07-04 01:45:36 -03:00
45688193d6 Phase 5: Prefix/Slash ergonomics, Registrar, presence delegate, gateway polish 2026-07-04 01:39:08 -03:00
f44d30954a fix(gateway): decode HELLO coalesced with the 101 upgrade; hermetic test env
Root cause of the order/seed-dependent integration failures: when the WS
upgrade's 101 response and the server's first frame (HELLO) arrive in one TCP
segment, Mint returns them as [status, headers, {:data, hello}, {:done}] - the
{:data} ordered BEFORE the {:done} that builds the websocket. process_responses
only decoded {:data} once the websocket existed, so the coalesced HELLO was
silently dropped and the client wedged in :hello_wait. Coalescing is far more
likely under full-suite scheduler load, which also starved the cadence-sensitive
tests - hence the flaky, seed-varying failures. Buffer pre-upgrade bytes and
decode them the instant the websocket is built.

Test hermeticity:
- Add Dexcord.EnvSandbox: snapshot all :dexcord app env in setup, restore
  wholesale on_exit. Replaces every file's hand-rolled per-key save/restore so no
  knob (timing bounds, budgets, ratelimit clock, base URLs) can leak between tests.
- gateway_fatal_test: await the directly-started supervisor's full termination
  (monitor + DOWN) instead of a fire-and-forget Process.exit, so its globally-named
  singletons and named ETS are gone before the next test's tree starts.
2026-07-04 01:25:48 -03:00
2dec62e72d Phase 4: ETS cache, event coverage, gateway send budget, member chunking
- Dexcord.Cache GenServer: creates 8 public ETS tables (me/guilds/channels/
  users sets; members/roles/presences/voice_states ordered_set keyed {guild_id,x}).
  Writes only via handle_dispatch/3 (single writer = Dispatcher, gateway-ordered);
  reads are direct ETS. Full event coverage incl. GUILD_DELETE cascade purge and
  cache_presences toggle. Read API + bang variants.
- Dispatcher: inline Cache.handle_dispatch before handler task; op-8 chunk request
  on real GUILD_CREATE when request_guild_members and :guild_members intent set.
- Gateway: send-budget token bucket (default 115/60s, heartbeats bypass); op-3/op-8
  consume tokens, queue when empty (bounded, drop+warn), drain on :send_refill ticks.
- Supervisor: Cache child after Session, before Finch.
- Config: validate cache_presences / request_guild_members are booleans.
2026-07-04 00:52:58 -03:00
92cb967a0f fix(api): harden REST rate limiter (probe death, token leak, deadline, herd)
- Monitor the elected probe caller and release parked waiters on its death;
  guarantee probe_settled via try/after around request issuance so a raising
  encode or killed caller can no longer wedge a route permanently.
- Never store secret tokens in public route keys: digest webhook tokens and
  collapse interaction-callback tokens to :id; add an idle-TTL sweep evicting
  stale bucket + route rows (:ratelimit_idle_ttl_ms / :ratelimit_sweep_interval_ms).
- Enforce a per-request deadline (:api_deadline_ms, default 30s); convert
  probe-park GenServer.call timeouts into an error tuple.
- Reset the counter to limit-1 on window elapse to avoid the reset thundering
  herd (un-deads the limit field); clamp the shared counter floor at -1.
- Preserve non-JSON bodies: {:ok, {:raw, body}} for 2xx, bounded snippet in
  error messages.
2026-07-04 00:47:12 -03:00
36 changed files with 4402 additions and 180 deletions

View file

@ -1 +0,0 @@
{"sessionId":"d0a8b446-9af2-42d3-81d4-b722b4678616","pid":18056,"procStart":"21098558","acquiredAt":1783128435683}

2
.gitignore vendored
View file

@ -22,3 +22,5 @@ erl_crash.dump
# Ignore package tarball (built via "mix hex.build"). # Ignore package tarball (built via "mix hex.build").
dexcord-*.tar dexcord-*.tar
.claude

495
README.md
View file

@ -1,21 +1,502 @@
# Dexcord # Dexcord
**TODO: Add description** 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 ## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed Dexcord isn't published on Hex yet. Depend on it via `path:` (local checkout)
by adding `dexcord` to your list of dependencies in `mix.exs`: or `git:`:
```elixir ```elixir
def deps do def deps do
[ [
{:dexcord, "~> 0.1.0"} {:dexcord, path: "../dexcord"}
# or:
# {:dexcord, git: "https://github.com/luna/dexcord.git"}
] ]
end end
``` ```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) ## Quickstart
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/dexcord>.
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, where `data` is the raw string-keyed payload map
Discord sent (no atom keys, no structs):
```elixir
defmodule MyBot.Handler do
use Dexcord.Handler
def handle_event({:MESSAGE_CREATE, msg}) do
IO.puts("#{msg["author"]["username"]}: #{msg["content"]}")
end
def handle_event({:PRESENCE_UPDATE, 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, msg) do
Dexcord.Api.create_message(msg["channel_id"], "pong")
end
def handle_command("echo", args, msg) do
Dexcord.Api.create_message(msg["channel_id"], Enum.join(args, " "))
end
end
defmodule MyBot.Handler do
use Dexcord.Handler
def handle_event({:MESSAGE_CREATE, msg}) do
Dexcord.Prefix.dispatch(msg, prefix: "!", to: MyBot.Commands)
end
end
```
`dispatch/2` skips messages authored by a bot (including the bot's own
messages, checked both via the payload's `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 (thin wrappers, string-keyed request/response maps):
```
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
create_interaction_response/3
edit_original_interaction_response/3
create_followup_message/3
bulk_overwrite_global_commands/2
bulk_overwrite_guild_commands/3
```
For anything not covered, `Dexcord.Api.request/4` is the escape hatch every
typed endpoint is built on:
```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 endpoints + `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) | string-keyed maps everywhere - `msg["content"]`, not `msg.content` |
| Auto-starting `:nostrum` application | you add `{Dexcord, opts}` to your own supervision tree |
The biggest day-to-day adjustment is the lack of structs: every payload -
messages, guilds, members, interactions - is the raw string-keyed map
Discord sent over the wire. This costs `msg["content"]` instead of
`msg.content`, but means Dexcord never has to guess a struct shape ahead of a
Discord API change, and never mints atoms from wire data.
## 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.

89
examples/echo_bot.exs Normal file
View file

@ -0,0 +1,89 @@
# Dexcord example bot: raw events, a prefix command, and a slash command.
#
# Run it from the repo root with:
#
# DISCORD_TOKEN=your-bot-token elixir examples/echo_bot.exs
#
# Optionally set DEV_GUILD_ID to a guild id to register the `/ping` slash
# command to that guild only (instant propagation, good for development).
# Without it, the command registers globally (propagation can take ~1h).
#
# What it demonstrates:
#
# * `intents: :all` - every documented gateway intent, including the
# privileged ones. If you see a 4014 (disallowed intents) error in the
# logs, enable SERVER MEMBERS / PRESENCE / MESSAGE CONTENT under
# Bot -> Privileged Gateway Intents in the Discord Developer Portal (see
# the README's "Privileged intents" section).
# * A raw `Dexcord.Handler` that logs every `MESSAGE_CREATE` and sets the
# bot's presence on `READY`.
# * A `!ping` prefix command via `Dexcord.Prefix.Router`.
# * A `/ping` slash command that responds ephemerally.
#
# This script does not connect to Discord on its own - it only starts if you
# provide a real DISCORD_TOKEN and run it, at which point it connects for
# real and stays running until you stop it (Ctrl-C twice).
Mix.install([
{:dexcord, path: Path.expand("..", __DIR__)}
])
defmodule EchoBot.Commands do
@moduledoc false
use Dexcord.Prefix.Router
def handle_command("ping", _args, msg) do
Dexcord.Api.create_message(msg["channel_id"], "pong")
end
end
defmodule EchoBot.Slash do
@moduledoc false
use Dexcord.Slash
def commands do
[%{name: "ping", description: "Replies with pong (only you can see it)."}]
end
def handle_interaction("ping", interaction) do
Dexcord.Slash.respond(interaction, %{content: "pong", ephemeral: true})
end
end
defmodule EchoBot.Handler do
@moduledoc false
use Dexcord.Handler
def handle_event({:READY, data}) do
IO.puts("Ready as #{get_in(data, ["user", "username"])}")
Dexcord.update_presence(%{
"since" => nil,
"activities" => [%{"name" => "!ping / /ping", "type" => 0}],
"status" => "online",
"afk" => false
})
end
def handle_event({:MESSAGE_CREATE, msg}) do
IO.puts("#{get_in(msg, ["author", "username"])}: #{msg["content"]}")
Dexcord.Prefix.dispatch(msg, prefix: "!", to: EchoBot.Commands)
end
end
token = System.fetch_env!("DISCORD_TOKEN")
dev_guild_id = System.get_env("DEV_GUILD_ID")
children = [
{Dexcord,
token: token,
handler: EchoBot.Handler,
intents: :all,
slash: EchoBot.Slash,
slash_guild_ids: if(dev_guild_id, do: [dev_guild_id])}
]
{:ok, _supervisor} =
Supervisor.start_link(children, strategy: :one_for_one, name: EchoBot.Supervisor)
Process.sleep(:infinity)

View file

@ -20,6 +20,11 @@ defmodule Dexcord do
* `:cache_presences` - cache PRESENCE_UPDATE (default `false`) * `:cache_presences` - cache PRESENCE_UPDATE (default `false`)
* `:request_guild_members` - request full member lists on GUILD_CREATE * `:request_guild_members` - request full member lists on GUILD_CREATE
(default `false`) (default `false`)
* `:slash` - a module using `Dexcord.Slash`. When set, `Dexcord.Slash.Registrar`
registers its `commands/0` at startup and `INTERACTION_CREATE`s are
auto-routed to it (the raw event still reaches the handler).
* `:slash_guild_ids` - a list of guild ids (strings or integers). When present,
slash commands are registered per guild (instant) instead of globally (~1h).
* `:gateway_url` - override the gateway URL (e.g. `"ws://127.0.0.1:4000"`); * `:gateway_url` - override the gateway URL (e.g. `"ws://127.0.0.1:4000"`);
when set, `GET /gateway/bot` is skipped. Primarily for testing. when set, `GET /gateway/bot` is skipped. Primarily for testing.
@ -27,6 +32,10 @@ defmodule Dexcord do
`Dexcord.Config` (`:persistent_term`) before the supervision tree starts. `Dexcord.Config` (`:persistent_term`) before the supervision tree starts.
""" """
@doc "Sends a presence update (op 3) through the gateway's send budget."
@spec update_presence(map()) :: :ok
defdelegate update_presence(presence), to: Dexcord.Gateway
@doc """ @doc """
Builds the supervisor child spec, validating options first. Builds the supervisor child spec, validating options first.
@ -58,19 +67,102 @@ defmodule Dexcord do
end end
intents = Keyword.get(opts, :intents, :default) intents = Keyword.get(opts, :intents, :default)
intents_bits = Dexcord.Intents.resolve(intents) intents_bits = resolve_intents(intents)
cache_presences = boolean_opt(opts, :cache_presences, false)
request_guild_members = boolean_opt(opts, :request_guild_members, false)
gateway_url = Keyword.get(opts, :gateway_url)
validate_gateway_url(gateway_url)
slash = Keyword.get(opts, :slash)
validate_slash(slash)
slash_guild_ids = Keyword.get(opts, :slash_guild_ids)
validate_slash_guild_ids(slash_guild_ids)
%{ %{
token: token, token: token,
handler: handler, handler: handler,
intents: intents_bits, intents: intents_bits,
intents_spec: intents, intents_spec: intents,
cache_presences: Keyword.get(opts, :cache_presences, false), cache_presences: cache_presences,
request_guild_members: Keyword.get(opts, :request_guild_members, false), request_guild_members: request_guild_members,
gateway_url: Keyword.get(opts, :gateway_url) slash: slash,
slash_guild_ids: slash_guild_ids,
gateway_url: gateway_url
} }
end end
# `Intents.resolve/1` raises a `FunctionClauseError` for a wrong-typed spec
# (a tuple, a negative integer, ...). Convert that into a friendly message; a
# list carrying an unknown intent already raises a helpful `ArgumentError`,
# which we let through.
defp resolve_intents(intents) do
Dexcord.Intents.resolve(intents)
rescue
FunctionClauseError ->
reraise ArgumentError,
[
message:
"Dexcord :intents must be :all, :default, a list of intent atoms, or a " <>
"non-negative integer, got: #{inspect(intents)}"
],
__STACKTRACE__
end
defp validate_gateway_url(nil), do: :ok
defp validate_gateway_url(url) when is_binary(url) do
unless String.starts_with?(url, ["ws://", "wss://", "http://", "https://"]) do
raise ArgumentError,
"Dexcord :gateway_url must start with ws://, wss://, http:// or https://, " <>
"got: #{inspect(url)}"
end
:ok
end
defp validate_gateway_url(other) do
raise ArgumentError,
"Dexcord :gateway_url must be a URL string or nil, got: #{inspect(other)}"
end
defp validate_slash(nil), do: :ok
defp validate_slash(mod) when is_atom(mod), do: :ok
defp validate_slash(other) do
raise ArgumentError, "Dexcord :slash must be a module, got: #{inspect(other)}"
end
defp validate_slash_guild_ids(nil), do: :ok
defp validate_slash_guild_ids(ids) when is_list(ids) do
unless Enum.all?(ids, &(is_binary(&1) or is_integer(&1))) do
raise ArgumentError,
"Dexcord :slash_guild_ids must be a list of guild ids (strings or integers), " <>
"got: #{inspect(ids)}"
end
:ok
end
defp validate_slash_guild_ids(other) do
raise ArgumentError,
"Dexcord :slash_guild_ids must be a list of guild ids (strings or integers), " <>
"got: #{inspect(other)}"
end
defp boolean_opt(opts, key, default) do
value = Keyword.get(opts, key, default)
unless is_boolean(value) do
raise ArgumentError, "Dexcord :#{key} must be a boolean, got: #{inspect(value)}"
end
value
end
defp require_opt(opts, key) do defp require_opt(opts, key) do
case Keyword.fetch(opts, key) do case Keyword.fetch(opts, key) do
{:ok, value} -> value {:ok, value} -> value

View file

@ -10,8 +10,9 @@ defmodule Dexcord.Api do
up to #{3} times. up to #{3} times.
All bodies are string-keyed maps; JSON encode/decode uses the built-in `JSON` All bodies are string-keyed maps; JSON encode/decode uses the built-in `JSON`
module. Returns `{:ok, map}`, `{:ok, nil}` (204 / empty 2xx body), or module. Returns `{:ok, map}`, `{:ok, nil}` (204 / empty 2xx body),
`{:error, %Dexcord.Api.Error{}}`. `{:ok, {:raw, body}}` (a non-JSON 2xx body), or
`{:error, %Dexcord.Api.Error{}}`. See `request/4` for the full contract.
The base URL defaults to `https://discord.com/api/v10` and is overridable with The base URL defaults to `https://discord.com/api/v10` and is overridable with
`config :dexcord, :api_base_url` (used by the integration tests to point at a `config :dexcord, :api_base_url` (used by the integration tests to point at a
@ -28,6 +29,11 @@ defmodule Dexcord.Api do
# Small cushion added to every computed sleep so we wake just after a window # Small cushion added to every computed sleep so we wake just after a window
# or retry-after boundary rather than a hair before it. # or retry-after boundary rather than a hair before it.
@wait_padding_ms 20 @wait_padding_ms 20
# Total wall-clock budget for a single `request/4` (all internal rate-limit
# waits + 429 retries), overridable with `config :dexcord, :api_deadline_ms`.
@default_deadline_ms 30_000
# Cap on the raw-body snippet kept in an error message for non-JSON responses.
@max_body_snippet 200
@doc """ @doc """
Performs a REST request. Performs a REST request.
@ -36,67 +42,106 @@ defmodule Dexcord.Api do
* `path` - path relative to the API base, e.g. `"/channels/123/messages"` * `path` - path relative to the API base, e.g. `"/channels/123/messages"`
* `body` - a map or list to JSON-encode, or `nil` for no body * `body` - a map or list to JSON-encode, or `nil` for no body
* `opts` - `:audit_log_reason` supported (sent as `x-audit-log-reason`) * `opts` - `:audit_log_reason` supported (sent as `x-audit-log-reason`)
## Returns
* `{:ok, map}` - a decoded JSON body
* `{:ok, nil}` - a 204 or empty 2xx body
* `{:ok, {:raw, binary}}` - a 2xx body that was **not** decodable JSON
(preserved verbatim rather than silently dropped)
* `{:error, %Dexcord.Api.Error{}}` - a 4xx/5xx, a transport failure, or a
rate-limit deadline (`status: nil`, message `"rate limit deadline exceeded"`)
when the internal waits would exceed `:api_deadline_ms`. For a non-JSON
error body a bounded snippet of the raw body is kept in `:message`.
""" """
@spec request(atom(), String.t(), map() | list() | nil, keyword()) :: @spec request(atom(), String.t(), map() | list() | nil, keyword()) ::
{:ok, map()} | {:ok, nil} | {:error, Error.t()} {:ok, map()} | {:ok, nil} | {:ok, {:raw, binary()}} | {:error, Error.t()}
def request(method, path, body \\ nil, opts \\ []) do def request(method, path, body \\ nil, opts \\ []) do
route = Ratelimit.route_key(method, path) route = Ratelimit.route_key(method, path)
do_request(method, path, body, opts, route, 0) deadline = mono_now() + deadline_ms()
do_request(method, path, body, opts, route, 0, deadline)
end end
defp do_request(method, path, body, opts, route, attempt) do defp do_request(method, path, body, opts, route, attempt, deadline) do
case Ratelimit.acquire(route) do case Ratelimit.acquire(route, max(deadline - mono_now(), 0)) do
{:wait, ms} -> {:wait, ms} ->
if mono_now() + ms >= deadline do
deadline_error()
else
Process.sleep(ms + @wait_padding_ms) Process.sleep(ms + @wait_padding_ms)
do_request(method, path, body, opts, route, attempt) do_request(method, path, body, opts, route, attempt, deadline)
end
{:error, :timeout} ->
deadline_error()
:ok -> :ok ->
issue(method, path, body, opts, route, attempt) issue(method, path, body, opts, route, attempt, deadline)
end end
end end
defp issue(method, path, body, opts, route, attempt) do defp issue(method, path, body, opts, route, attempt, deadline) do
# Guarantee the probe is released even if encoding raises or this process is
# killed mid-request: `probe_settled` must run before we ever recurse.
result =
try do
do_issue(method, path, body, opts, route)
after
Ratelimit.probe_settled(route)
end
case result do
{:retry_429, headers, resp_body} ->
handle_429(method, path, body, opts, route, attempt, headers, resp_body, deadline)
{:done, done} ->
done
end
end
defp do_issue(method, path, body, opts, route) do
{headers, encoded} = build(body, opts) {headers, encoded} = build(body, opts)
request = Finch.build(method, base_url() <> path, headers, encoded) request = Finch.build(method, base_url() <> path, headers, encoded)
case Finch.request(request, @finch) do case Finch.request(request, @finch) do
{:ok, %Finch.Response{status: 429, headers: resp_headers, body: resp_body}} -> {:ok, %Finch.Response{status: 429, headers: resp_headers, body: resp_body}} ->
Ratelimit.update(route, resp_headers) Ratelimit.update(route, resp_headers)
Ratelimit.probe_settled(route) {:retry_429, resp_headers, resp_body}
handle_429(method, path, body, opts, route, attempt, resp_headers, resp_body)
{:ok, %Finch.Response{status: status, headers: resp_headers} = resp} {:ok, %Finch.Response{status: status, headers: resp_headers} = resp}
when status in 200..299 -> when status in 200..299 ->
Ratelimit.update(route, resp_headers) Ratelimit.update(route, resp_headers)
Ratelimit.probe_settled(route) {:done, success_result(status, resp.body)}
if status == 204, do: {:ok, nil}, else: {:ok, decode_body(resp.body)}
{:ok, %Finch.Response{headers: resp_headers} = resp} -> {:ok, %Finch.Response{headers: resp_headers} = resp} ->
Ratelimit.update(route, resp_headers) Ratelimit.update(route, resp_headers)
Ratelimit.probe_settled(route) {:done, {:error, error(resp)}}
{:error, error(resp)}
{:error, reason} -> {:error, reason} ->
Ratelimit.probe_settled(route) {:done, {:error, %Error{status: nil, code: nil, message: inspect(reason), errors: nil}}}
{:error, %Error{status: nil, code: nil, message: inspect(reason), errors: nil}}
end end
end end
defp handle_429(method, path, body, opts, route, attempt, headers, resp_body) defp handle_429(method, path, body, opts, route, attempt, headers, resp_body, deadline)
when attempt < @max_retries do when attempt < @max_retries do
retry_ms = retry_after_ms(resp_body, headers) retry_ms = retry_after_ms(resp_body, headers)
if global_429?(headers, resp_body) do cond do
global_429?(headers, resp_body) ->
# The global lock now gates re-acquire; don't double-sleep here. # The global lock now gates re-acquire; don't double-sleep here.
Ratelimit.set_global_lock(retry_ms) Ratelimit.set_global_lock(retry_ms)
else do_request(method, path, body, opts, route, attempt + 1, deadline)
mono_now() + retry_ms >= deadline ->
deadline_error()
true ->
Process.sleep(retry_ms + @wait_padding_ms) Process.sleep(retry_ms + @wait_padding_ms)
do_request(method, path, body, opts, route, attempt + 1, deadline)
end
end end
do_request(method, path, body, opts, route, attempt + 1) defp handle_429(_method, _path, _body, _opts, _route, _attempt, _headers, resp_body, _deadline) do
end
defp handle_429(_method, _path, _body, _opts, _route, _attempt, _headers, resp_body) do
decoded = decoded_map(resp_body) decoded = decoded_map(resp_body)
{:error, {:error,
@ -131,6 +176,27 @@ defmodule Dexcord.Api do
defp base_url, do: Application.get_env(:dexcord, :api_base_url, @default_base_url) defp base_url, do: Application.get_env(:dexcord, :api_base_url, @default_base_url)
defp deadline_ms, do: Application.get_env(:dexcord, :api_deadline_ms, @default_deadline_ms)
defp mono_now, do: System.monotonic_time(:millisecond)
defp deadline_error do
{:error, %Error{status: nil, code: nil, message: "rate limit deadline exceeded", errors: nil}}
end
# A 2xx body: nil for empty/204, the decoded JSON, or `{:raw, body}` when it is
# present but not decodable JSON (preserved rather than dropped to `nil`).
defp success_result(204, _body), do: {:ok, nil}
defp success_result(_status, body) when body in ["", nil], do: {:ok, nil}
defp success_result(_status, body) do
case JSON.decode(body) do
{:ok, decoded} -> {:ok, decoded}
{:error, _} -> {:ok, {:raw, body}}
end
end
defp decode_body(""), do: nil defp decode_body(""), do: nil
defp decode_body(nil), do: nil defp decode_body(nil), do: nil
@ -149,15 +215,27 @@ defmodule Dexcord.Api do
end end
defp error(%Finch.Response{status: status, body: body}) do defp error(%Finch.Response{status: status, body: body}) do
decoded = decoded_map(body) case decode_body(body) do
map when is_map(map) ->
%Error{ %Error{
status: status, status: status,
code: Map.get(decoded, "code"), code: Map.get(map, "code"),
message: Map.get(decoded, "message"), message: Map.get(map, "message"),
errors: Map.get(decoded, "errors") errors: Map.get(map, "errors")
} }
_ ->
# Non-JSON error body (e.g. a proxy's HTML 5xx): keep a bounded snippet
# rather than discarding it, so the failure is diagnosable.
%Error{status: status, code: nil, message: body_snippet(body), errors: nil}
end end
end
defp body_snippet(body) when is_binary(body) and body != "" do
binary_part(body, 0, min(byte_size(body), @max_body_snippet))
end
defp body_snippet(_body), do: nil
defp retry_after_ms(body, headers) do defp retry_after_ms(body, headers) do
from_body = from_body =

View file

@ -9,15 +9,20 @@ defmodule Dexcord.Api.Ratelimit do
response for that route (via the `X-RateLimit-Bucket` header). Until then a response for that route (via the `X-RateLimit-Bucket` header). Until then a
route's bucket is unknown. So the model is: route's bucket is unknown. So the model is:
* **`acquire/1`** runs in the *caller's* process. For a known route it reads * **`acquire/2`** runs in the *caller's* process. For a known route it reads
the shared, public ETS table `:dexcord_ratelimit` and takes a token with an the shared, public ETS table `:dexcord_ratelimit` and takes a token with an
atomic `:ets.update_counter/3` - no GenServer round-trip, no I/O in this atomic `:ets.update_counter/3` - no GenServer round-trip, no I/O in this
process ever waits on the server. It returns `:ok` or `{:wait, ms}`; the process ever waits on the server. It returns `:ok` or `{:wait, ms}`; the
caller sleeps and retries. caller sleeps and retries.
* For an **unknown route**, `acquire/1` serializes through the GenServer so * For an **unknown route**, `acquire/2` serializes through the GenServer so
that N concurrent first-hits don't all fire before the bucket is learned - that N concurrent first-hits don't all fire before the bucket is learned -
exactly one becomes the "probe" (`:ok`), the rest park until the probe exactly one becomes the "probe" (`:ok`), the rest park until the probe
settles and then re-`acquire/1` against the freshly learned bucket. settles and then re-`acquire/2` against the freshly learned bucket. The
elected probe is monitored: if it dies before `probe_settled/1`, the parked
waiters are released so the route can't wedge permanently. A probe that
*stays alive* but abandons on a deadline timeout (its `:ok` election raced a
dead ref) explicitly `abandon_probe/1`s the route; a server-side
force-settle timer is the final backstop against a lost abandon.
* **`update/2`** feeds a response's headers back in (through the GenServer): * **`update/2`** feeds a response's headers back in (through the GenServer):
it learns the `X-RateLimit-Bucket` routebucket mapping and stores the it learns the `X-RateLimit-Bucket` routebucket mapping and stores the
window's `remaining` plus a **monotonic** reset deadline derived from window's `remaining` plus a **monotonic** reset deadline derived from
@ -33,6 +38,13 @@ defmodule Dexcord.Api.Ratelimit do
zero-arity function. Tests inject a controllable clock so the bucket math is zero-arity function. Tests inject a controllable clock so the bucket math is
deterministic with no real sleeping. deterministic with no real sleeping.
## Idle-TTL eviction
A periodic sweep drops window state and routebucket mappings that have been
idle past their reset deadline for longer than `:ratelimit_idle_ttl_ms`
(default 10 min), on `:ratelimit_sweep_interval_ms` (default 60 s). This keeps
token-scoped routes (webhook followups) from accumulating rows forever.
## ETS layout (`:dexcord_ratelimit`, public) ## ETS layout (`:dexcord_ratelimit`, public)
* `{:global_lock_until, mono_ms}` - global lock deadline * `{:global_lock_until, mono_ms}` - global lock deadline
@ -47,6 +59,9 @@ defmodule Dexcord.Api.Ratelimit do
@table :dexcord_ratelimit @table :dexcord_ratelimit
@server __MODULE__ @server __MODULE__
# Short provisional window used at reset time, before the real headers land.
@provisional_reset_ms 1_000
# --- Public API --------------------------------------------------------- # --- Public API ---------------------------------------------------------
@doc false @doc false
@ -62,10 +77,18 @@ defmodule Dexcord.Api.Ratelimit do
Pure function (exported for tests). The key is `"METHOD /template"` where the Pure function (exported for tests). The key is `"METHOD /template"` where the
template keeps the **major** parameters literal - `channel_id` and `guild_id` template keeps the **major** parameters literal - `channel_id` and `guild_id`
(the segment after `channels`/`guilds`) and `webhook_id` + `webhook_token` (the segment after `channels`/`guilds`) and `webhook_id` (the id segment after
(the two segments after `webhooks`) - collapses every other snowflake and the `webhooks`) - collapses every other snowflake and the reaction emoji to `:id`,
reaction emoji to `:id`, and gives `DELETE /messages/:id` its own key family and gives `DELETE /messages/:id` its own key family (Discord rate-limits
(Discord rate-limits message deletes separately). message deletes separately).
Secret tokens are **never** stored literally in a key (they would leak into the
public ETS table): the `webhook_token` segment is replaced with a short,
non-reversible SHA-256 digest so bucketing stays token-scoped without keeping
the secret. The `interaction_token` in `/interactions/:id/<token>/callback` gets
the same treatment - a per-interaction digest, never the raw token - so one
interaction's `Remaining: 0`/429 can't stall every other interaction's ack
toward Discord's 3 s callback deadline.
iex> Dexcord.Api.Ratelimit.route_key(:post, "/channels/123/messages") iex> Dexcord.Api.Ratelimit.route_key(:post, "/channels/123/messages")
"POST /channels/123/messages" "POST /channels/123/messages"
@ -92,13 +115,21 @@ defmodule Dexcord.Api.Ratelimit do
end end
end end
# webhook_id + webhook_token are both major - keep both literal. # webhook_id is major (literal); the webhook_token is a secret - keep bucketing
# token-scoped via a short non-reversible digest instead of storing it raw.
defp templatize(["webhooks", id, token | rest]), defp templatize(["webhooks", id, token | rest]),
do: ["webhooks", id, token | templatize(rest)] do: ["webhooks", id, token_digest(token) | templatize(rest)]
defp templatize(["webhooks", id | rest]), defp templatize(["webhooks", id | rest]),
do: ["webhooks", id | templatize(rest)] do: ["webhooks", id | templatize(rest)]
# interaction_token is a secret, but each interaction needs its OWN bucket: a
# 429 on one callback must not stall every other interaction's ack. Keep
# per-interaction separation via the same non-reversible digest used for
# webhooks (never the raw token); the minor interaction id still collapses.
defp templatize(["interactions", id, token | rest]),
do: ["interactions", templatize_one(id), token_digest(token) | templatize(rest)]
# channel_id / guild_id are major - keep the id literal. # channel_id / guild_id are major - keep the id literal.
defp templatize([major, id | rest]) when major in ["channels", "guilds"], defp templatize([major, id | rest]) when major in ["channels", "guilds"],
do: [major, id | templatize(rest)] do: [major, id | templatize(rest)]
@ -116,14 +147,27 @@ defmodule Dexcord.Api.Ratelimit do
defp snowflake?(seg), do: seg != "" and String.match?(seg, ~r/^\d+$/) defp snowflake?(seg), do: seg != "" and String.match?(seg, ~r/^\d+$/)
# Short, non-reversible token digest: 12 url-safe base64 chars of SHA-256.
defp token_digest(token) do
:crypto.hash(:sha256, token)
|> Base.url_encode64(padding: false)
|> binary_part(0, 12)
end
@doc """ @doc """
Attempts to take a token for `route_key`, in the calling process. Attempts to take a token for `route_key`, in the calling process.
Returns `:ok` to proceed, or `{:wait, ms}` telling the caller to sleep that Returns `:ok` to proceed, or `{:wait, ms}` telling the caller to sleep that
long and try again. A global lock takes precedence over any per-bucket state. long and try again. A global lock takes precedence over any per-bucket state.
`probe_timeout` bounds the GenServer round-trip taken only for an *unknown*
route (the probe-park path); on timeout it returns `{:error, :timeout}` rather
than crashing the caller with an EXIT, so a total-deadline can be enforced.
Defaults to `:infinity` (known routes never make the call).
""" """
@spec acquire(String.t()) :: :ok | {:wait, non_neg_integer()} @spec acquire(String.t(), timeout()) ::
def acquire(route_key) do :ok | {:wait, non_neg_integer()} | {:error, :timeout}
def acquire(route_key, probe_timeout \\ :infinity) do
now = now() now = now()
case global_lock_wait(now) do case global_lock_wait(now) do
@ -132,7 +176,7 @@ defmodule Dexcord.Api.Ratelimit do
:ok -> :ok ->
case lookup_bucket(route_key) do case lookup_bucket(route_key) do
nil -> acquire_unknown(route_key) nil -> acquire_unknown(route_key, probe_timeout)
:none -> :ok :none -> :ok
bucket -> take_token(bucket, now) bucket -> take_token(bucket, now)
end end
@ -160,13 +204,31 @@ defmodule Dexcord.Api.Ratelimit do
@doc """ @doc """
Signals that a route's probe request has completed (success or failure), Signals that a route's probe request has completed (success or failure),
releasing any callers parked behind it so they re-`acquire/1`. releasing any callers parked behind it so they re-`acquire/2`.
""" """
@spec probe_settled(String.t()) :: :ok @spec probe_settled(String.t()) :: :ok
def probe_settled(route_key) do def probe_settled(route_key) do
GenServer.cast(@server, {:probe_settled, route_key}) GenServer.cast(@server, {:probe_settled, route_key})
end end
@doc false
# Explicitly relinquishes an *unsettled* probe that the caller was elected for
# but then abandoned (its `acquire/2` GenServer.call timed out on a nearly-spent
# deadline while the caller stays alive - so no `:DOWN` fires and
# `probe_settled/1` never runs). The server only settles the entry if its
# monitored pid still matches `self()`, so a later, unrelated probe on the same
# route can never be clobbered by a stale abandon.
@spec abandon_probe(String.t()) :: :ok
def abandon_probe(route_key) do
GenServer.cast(@server, {:abandon_probe, route_key, self()})
end
@doc false
# Runs the idle-TTL eviction sweep synchronously (exported for tests). The
# periodic timer runs the same pass on `:ratelimit_sweep_interval_ms`.
@spec sweep() :: :ok
def sweep, do: GenServer.call(@server, :sweep)
@doc "The monotonic clock in ms, honoring an injected `:ratelimit_now_fn`." @doc "The monotonic clock in ms, honoring an injected `:ratelimit_now_fn`."
@spec now() :: integer() @spec now() :: integer()
def now do def now do
@ -178,11 +240,20 @@ defmodule Dexcord.Api.Ratelimit do
# --- caller-process internals ------------------------------------------ # --- caller-process internals ------------------------------------------
defp acquire_unknown(route_key) do defp acquire_unknown(route_key, probe_timeout) do
case GenServer.call(@server, {:acquire_probe, route_key}) do case GenServer.call(@server, {:acquire_probe, route_key, probe_timeout}, probe_timeout) do
:ok -> :ok :ok -> :ok
:retry -> acquire(route_key) :retry -> acquire(route_key, probe_timeout)
end end
catch
# Parked (or elected) longer than the caller's deadline allows: surface an
# error tuple instead of the raw GenServer.call timeout EXIT. We may have been
# elected the probe server-side just as we gave up (the `:ok` reply raced our
# timeout to a now-dead ref) - since we stay alive, no `:DOWN` will ever fire,
# so explicitly abandon the probe to avoid wedging the route permanently.
:exit, {:timeout, _} ->
abandon_probe(route_key)
{:error, :timeout}
end end
defp lookup_bucket(route_key) do defp lookup_bucket(route_key) do
@ -197,20 +268,41 @@ defmodule Dexcord.Api.Ratelimit do
[] -> [] ->
:ok :ok
[{_, _remaining, reset_at, _limit}] -> [{_, _remaining, reset_at, limit}] ->
cond do cond do
is_integer(reset_at) and now >= reset_at -> is_integer(reset_at) and now >= reset_at ->
# Window elapsed: allow optimistically; the next response's headers # Window elapsed: reset the counter to `limit - 1` under a fresh
# refresh `remaining`/`reset_at` for the new window. # provisional deadline so only ~limit concurrent callers pass before
:ok # the next response's headers land - instead of admitting everyone
# (the window-reset thundering herd).
reset_window(bucket, reset_at, limit, now)
true -> true ->
new = :ets.update_counter(@table, {:bucket, bucket}, {2, -1}) # The shared counter is a distributed, deliberately over-permissive
# gate: concurrent callers can momentarily race past `remaining`.
# Clamp the floor at -1 so a burst of parked callers can't drive it
# arbitrarily negative (any value < 0 already means "wait").
new = :ets.update_counter(@table, {:bucket, bucket}, {2, -1, -1, -1})
if new >= 0, do: :ok, else: {:wait, wait_until(reset_at, now)} if new >= 0, do: :ok, else: {:wait, wait_until(reset_at, now)}
end end
end end
end end
# CAS the expired row to a fresh window; retry if another caller beat us to it.
defp reset_window(bucket, old_reset_at, limit, now) when is_integer(limit) and limit > 0 do
key = {:bucket, bucket}
match = {key, :"$1", old_reset_at, limit}
replacement = {key, limit - 1, now + @provisional_reset_ms, limit}
case :ets.select_replace(@table, [{match, [], [{:const, replacement}]}]) do
1 -> :ok
0 -> take_token(bucket, now)
end
end
# No usable limit header yet: fall back to admitting optimistically.
defp reset_window(_bucket, _old_reset_at, _limit, _now), do: :ok
defp wait_until(reset_at, now) when is_integer(reset_at), do: max(reset_at - now, 0) + 1 defp wait_until(reset_at, now) when is_integer(reset_at), do: max(reset_at - now, 0) + 1
defp wait_until(_reset_at, _now), do: 1 defp wait_until(_reset_at, _now), do: 1
@ -233,11 +325,18 @@ defmodule Dexcord.Api.Ratelimit do
write_concurrency: true write_concurrency: true
]) ])
{:ok, %{probing: %{}}} schedule_sweep()
# `probing`: route_key => {probe_monitor_ref, [parked_waiters]}
# `mons`: probe_monitor_ref => route_key (reverse lookup for :DOWN)
{:ok, %{probing: %{}, mons: %{}}}
end end
@impl true @impl true
def handle_call({:acquire_probe, route_key}, from, %{probing: probing} = state) do def handle_call(
{:acquire_probe, route_key, probe_timeout},
from,
%{probing: probing, mons: mons} = state
) do
cond do cond do
:ets.member(@table, {:route_bucket, route_key}) -> :ets.member(@table, {:route_bucket, route_key}) ->
# Learned since the caller's ETS read - just retry the fast path. # Learned since the caller's ETS read - just retry the fast path.
@ -245,14 +344,43 @@ defmodule Dexcord.Api.Ratelimit do
Map.has_key?(probing, route_key) -> Map.has_key?(probing, route_key) ->
# A probe is already in flight; park until it settles. # A probe is already in flight; park until it settles.
waiters = Map.fetch!(probing, route_key) {ref, pid, timer, waiters} = Map.fetch!(probing, route_key)
{:noreply, %{state | probing: Map.put(probing, route_key, [from | waiters])}}
{:noreply,
%{state | probing: Map.put(probing, route_key, {ref, pid, timer, [from | waiters]})}}
true -> true ->
{:reply, :ok, %{state | probing: Map.put(probing, route_key, [])}} # Elect this caller the probe and monitor it: if it dies before it can
# call `probe_settled/1` (killed Task, a raise before the request, ...),
# the :DOWN handler releases parked waiters instead of wedging the route
# forever. Belt-and-braces for the caller that stays ALIVE but never
# settles (deadline-timeout abandon that also loses its abandon cast): a
# generous force-settle timer relinquishes a probe still holding this same
# ref, so the route can never wedge permanently.
{pid, _tag} = from
ref = Process.monitor(pid)
timer =
Process.send_after(
self(),
{:force_settle, route_key, ref},
force_settle_ms(probe_timeout)
)
{:reply, :ok,
%{
state
| probing: Map.put(probing, route_key, {ref, pid, timer, []}),
mons: Map.put(mons, ref, route_key)
}}
end end
end end
def handle_call(:sweep, _from, state) do
do_sweep(idle_ttl())
{:reply, :ok, state}
end
def handle_call({:update, route_key, headers}, _from, state) do def handle_call({:update, route_key, headers}, _from, state) do
h = downcase_headers(headers) h = downcase_headers(headers)
@ -283,17 +411,105 @@ defmodule Dexcord.Api.Ratelimit do
end end
@impl true @impl true
def handle_cast({:probe_settled, route_key}, %{probing: probing} = state) do def handle_cast({:probe_settled, route_key}, state) do
{:noreply, release_probe(state, route_key)}
end
# A caller that was elected the probe but abandoned it on a deadline timeout.
# Only settle if the entry's monitored pid still matches the abandoning pid -
# otherwise this is a stale abandon and a *different*, later probe now holds the
# route, which must not be clobbered.
def handle_cast({:abandon_probe, route_key, pid}, %{probing: probing} = state) do
case Map.get(probing, route_key) do
{_ref, ^pid, _timer, _waiters} -> {:noreply, release_probe(state, route_key)}
_ -> {:noreply, state}
end
end
@impl true
def handle_info({:DOWN, ref, :process, _pid, _reason}, %{mons: mons} = state) do
case Map.get(mons, ref) do
nil -> {:noreply, state}
route_key -> {:noreply, release_probe(state, route_key)}
end
end
# Belt-and-braces force-settle: only fires if the route is still being probed by
# the exact same election (same ref). A settled/re-elected probe has a different
# ref (or no entry), so this can never clobber a healthy in-flight probe.
def handle_info({:force_settle, route_key, ref}, %{probing: probing} = state) do
case Map.get(probing, route_key) do
{^ref, _pid, _timer, _waiters} -> {:noreply, release_probe(state, route_key)}
_ -> {:noreply, state}
end
end
def handle_info(:sweep, state) do
do_sweep(idle_ttl())
schedule_sweep()
{:noreply, state}
end
# Drops the probe for `route_key` and releases its parked waiters to re-acquire
# (they either take the freshly learned bucket or re-elect a new probe).
defp release_probe(%{probing: probing, mons: mons} = state, route_key) do
case Map.pop(probing, route_key) do case Map.pop(probing, route_key) do
{nil, _} -> {nil, _} ->
{:noreply, state} state
{waiters, rest} -> {{ref, _pid, timer, waiters}, rest} ->
if is_reference(ref), do: Process.demonitor(ref, [:flush])
if is_reference(timer), do: Process.cancel_timer(timer)
Enum.each(waiters, &GenServer.reply(&1, :retry)) Enum.each(waiters, &GenServer.reply(&1, :retry))
{:noreply, %{state | probing: rest}} %{state | probing: rest, mons: Map.delete(mons, ref)}
end end
end end
# Generous force-settle deadline for an elected probe: twice the caller's own
# probe timeout, floored at 30 s (a probe that outlives this is almost certainly
# wedged). Overridable with `:ratelimit_probe_force_settle_ms` for deterministic
# tests; `:infinity` probe timeouts fall back to the 30 s floor.
defp force_settle_ms(probe_timeout) do
case Application.get_env(:dexcord, :ratelimit_probe_force_settle_ms) do
ms when is_integer(ms) and ms > 0 -> ms
_ -> default_force_settle_ms(probe_timeout)
end
end
defp default_force_settle_ms(ms) when is_integer(ms), do: max(ms * 2, 30_000)
defp default_force_settle_ms(_), do: 30_000
# --- idle-TTL eviction --------------------------------------------------
# Evicts window state that has been idle past its reset deadline for longer
# than the configured TTL, plus route→bucket mappings whose bucket row is gone.
# Without this every distinct token-scoped route (e.g. webhook followups) would
# accumulate a never-reclaimed row on a long-running bot.
defp do_sweep(ttl) do
cutoff = now() - ttl
# Expired bucket rows: {{:bucket, hash}, remaining, reset_at, limit}.
:ets.select_delete(@table, [
{{{:bucket, :"$1"}, :_, :"$2", :_}, [{:is_integer, :"$2"}, {:<, :"$2", cutoff}], [true]}
])
# Route mappings whose target bucket row no longer exists (`:none` mappings
# are a small static set - leave them).
@table
|> :ets.match_object({{:route_bucket, :_}, :_})
|> Enum.each(fn
{_key, :none} -> :ok
{key, hash} -> unless :ets.member(@table, {:bucket, hash}), do: :ets.delete(@table, key)
end)
:ok
end
defp schedule_sweep, do: Process.send_after(self(), :sweep, sweep_interval())
defp idle_ttl, do: Application.get_env(:dexcord, :ratelimit_idle_ttl_ms, 600_000)
defp sweep_interval, do: Application.get_env(:dexcord, :ratelimit_sweep_interval_ms, 60_000)
# --- header parsing ----------------------------------------------------- # --- header parsing -----------------------------------------------------
defp downcase_headers(headers) do defp downcase_headers(headers) do

442
lib/dexcord/cache.ex Normal file
View file

@ -0,0 +1,442 @@
defmodule Dexcord.Cache do
@moduledoc """
A per-entity ETS cache of the gateway event stream.
Under `intents: :all` the gateway delivers a firehose describing the current
state of every guild the bot is in. `Dexcord.Cache` folds that stream into a set
of ETS tables so a handler (or any other process) can look up guilds, channels,
members, roles, users, presences and voice states without a REST round-trip.
## Single writer, lock-free reads
This GenServer only *creates* the tables (in `init/1`); it never writes to them
on the hot path. All writes happen through `handle_dispatch/3`, which the
`Dexcord.Dispatcher` calls **inline, in its own process, in gateway order** -
making the Dispatcher the sole writer. Because there is exactly one writer, no
locks are needed. Every table is `:public`, so reads (`guild/1`, `member/2`, ...)
go straight to ETS from any process with no GenServer round-trip.
If this process ever crashes the tables are lost and recreated empty on restart;
the documented cost is a cold cache until the next relevant events arrive. REST
is always the source of truth; the cache is best-effort, and can briefly lag or
hold a duplicate after a resume gap.
## Tables
| Table | Type | Key |
|------------------------|---------------|---------------------|
| `:dexcord_me` | `set` | `:me` |
| `:dexcord_guilds` | `set` | `guild_id` |
| `:dexcord_channels` | `set` | `channel_id` |
| `:dexcord_users` | `set` | `user_id` |
| `:dexcord_members` | `ordered_set` | `{guild_id, user_id}` |
| `:dexcord_roles` | `ordered_set` | `{guild_id, role_id}` |
| `:dexcord_presences` | `ordered_set` | `{guild_id, user_id}` |
| `:dexcord_voice_states`| `ordered_set` | `{guild_id, user_id}` |
### Key and value conventions
Snowflake ids are stored in table keys as the **raw strings Discord sends** - no
integer parsing. This keeps lookups cheap and avoids precision pitfalls, at the
cost that a caller must pass string ids (which is what every payload already
carries). Stored values are the original string-keyed payload maps (no structs,
no atom keys), with the large child arrays of a guild peeled off into their own
tables. The `ordered_set` tables use `{guild_id, x}` keys so a whole guild can be
listed with a key-prefix select and purged with a single `match_delete/2`.
"""
use GenServer
@me :dexcord_me
@guilds :dexcord_guilds
@channels :dexcord_channels
@users :dexcord_users
@members :dexcord_members
@roles :dexcord_roles
@presences :dexcord_presences
@voice_states :dexcord_voice_states
# Guild child collections lifted out of the guild row into their own tables.
# `emojis` (and stickers) stay inline on the guild and are replaced wholesale.
@guild_child_arrays ~w(channels threads roles members presences voice_states)
# --- lifecycle ----------------------------------------------------------
@doc false
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
set = [:set, :public, :named_table, read_concurrency: true]
oset = [:ordered_set, :public, :named_table, read_concurrency: true]
:ets.new(@me, set)
:ets.new(@guilds, set)
:ets.new(@channels, set)
:ets.new(@users, set)
:ets.new(@members, oset)
:ets.new(@roles, oset)
:ets.new(@presences, oset)
:ets.new(@voice_states, oset)
{:ok, %{}}
end
# --- write path (called inline by the Dispatcher, single writer) ---------
@doc """
Folds one dispatch event into the cache. Called inline by `Dexcord.Dispatcher`
*before* the user handler runs, so the handler always observes a cache that
already reflects the event.
`config` is the resolved `Dexcord.Config` map; only `:cache_presences` is
consulted (presence writes are skipped entirely when it is falsey). Unknown or
uninteresting events - and any event whose data is not a map - are a no-op.
Returns `:ok`.
"""
@spec handle_dispatch(atom() | {:unknown_event, String.t()}, term(), map()) :: :ok
def handle_dispatch(name, data, config)
def handle_dispatch(_name, data, _config) when not is_map(data), do: :ok
def handle_dispatch(:READY, data, _config) do
if user = data["user"], do: put_me(user)
for g <- data["guilds"] || [],
is_map(g) and is_binary(g["id"]),
do: :ets.insert(@guilds, {g["id"], g})
:ok
end
def handle_dispatch(:GUILD_CREATE, guild, config) do
gid = guild["id"]
:ets.insert(@guilds, {gid, Map.drop(guild, @guild_child_arrays)})
for ch <- guild["channels"] || [], do: put_channel(Map.put(ch, "guild_id", gid))
for th <- guild["threads"] || [], do: put_channel(Map.put(th, "guild_id", gid))
for role <- guild["roles"] || [],
is_binary(role["id"]),
do: :ets.insert(@roles, {{gid, role["id"]}, role})
for vs <- guild["voice_states"] || [], do: put_voice_state(gid, vs)
for m <- guild["members"] || [], do: put_member(gid, m)
if cache_presences?(config) do
for p <- guild["presences"] || [], do: put_presence(gid, p)
end
:ok
end
def handle_dispatch(:GUILD_UPDATE, guild, _config) do
gid = guild["id"]
merged = Map.merge(existing(@guilds, gid), Map.drop(guild, @guild_child_arrays))
:ets.insert(@guilds, {gid, merged})
:ok
end
def handle_dispatch(:GUILD_DELETE, data, _config) do
gid = data["id"]
if data["unavailable"] == true do
# Guild went unavailable (outage), not removed: keep a stub placeholder.
:ets.insert(@guilds, {gid, data})
else
:ets.delete(@guilds, gid)
:ets.match_delete(@members, {{gid, :_}, :_})
:ets.match_delete(@roles, {{gid, :_}, :_})
:ets.match_delete(@presences, {{gid, :_}, :_})
:ets.match_delete(@voice_states, {{gid, :_}, :_})
:ets.select_delete(@channels, [{{:_, %{"guild_id" => gid}}, [], [true]}])
end
:ok
end
def handle_dispatch(name, ch, _config)
when name in [:CHANNEL_CREATE, :CHANNEL_UPDATE, :THREAD_CREATE, :THREAD_UPDATE] do
put_channel(ch)
:ok
end
def handle_dispatch(name, ch, _config) when name in [:CHANNEL_DELETE, :THREAD_DELETE] do
if id = ch["id"], do: :ets.delete(@channels, id)
:ok
end
def handle_dispatch(name, member, _config)
when name in [:GUILD_MEMBER_ADD, :GUILD_MEMBER_UPDATE] do
put_member(member["guild_id"], member)
:ok
end
def handle_dispatch(:GUILD_MEMBER_REMOVE, data, _config) do
with gid when is_binary(gid) <- data["guild_id"],
%{"id" => uid} <- data["user"] do
:ets.delete(@members, {gid, uid})
end
:ok
end
def handle_dispatch(:GUILD_MEMBERS_CHUNK, data, config) do
gid = data["guild_id"]
for m <- data["members"] || [], do: put_member(gid, m)
presences = data["presences"]
if is_list(presences) and cache_presences?(config) do
for p <- presences, do: put_presence(gid, p)
end
:ok
end
def handle_dispatch(name, data, _config)
when name in [:GUILD_ROLE_CREATE, :GUILD_ROLE_UPDATE] do
gid = data["guild_id"]
case data["role"] do
%{"id" => rid} = role -> :ets.insert(@roles, {{gid, rid}, role})
_ -> :ok
end
:ok
end
def handle_dispatch(:GUILD_ROLE_DELETE, data, _config) do
if is_binary(data["guild_id"]) and is_binary(data["role_id"]),
do: :ets.delete(@roles, {data["guild_id"], data["role_id"]})
:ok
end
def handle_dispatch(:GUILD_EMOJIS_UPDATE, data, _config) do
gid = data["guild_id"]
case :ets.lookup(@guilds, gid) do
[{_, g}] -> :ets.insert(@guilds, {gid, Map.put(g, "emojis", data["emojis"] || [])})
[] -> :ok
end
:ok
end
def handle_dispatch(:PRESENCE_UPDATE, data, config) do
# The chattiest event under `:all`: when presences aren't cached we skip the
# write entirely rather than build-then-discard a term.
if cache_presences?(config), do: put_presence(data["guild_id"], data)
:ok
end
def handle_dispatch(:VOICE_STATE_UPDATE, data, _config) do
gid = data["guild_id"]
uid = data["user_id"]
cond do
is_nil(gid) or is_nil(uid) -> :ok
is_nil(data["channel_id"]) -> :ets.delete(@voice_states, {gid, uid})
true -> :ets.insert(@voice_states, {{gid, uid}, data})
end
:ok
end
def handle_dispatch(:USER_UPDATE, data, _config) do
put_me(data)
:ok
end
def handle_dispatch(:MESSAGE_CREATE, data, _config) do
author = data["author"]
# Opportunistic freshness: cache the (real, non-webhook) author and, when the
# message carries a guild member fragment, fold it into the members table.
if is_map(author) and is_binary(author["id"]) and is_nil(author["webhook_id"]) and
is_nil(data["webhook_id"]) do
upsert_user(author)
with gid when is_binary(gid) <- data["guild_id"],
member when is_map(member) <- data["member"] do
uid = author["id"]
merged = existing(@members, {gid, uid}) |> Map.merge(member) |> Map.put("user_id", uid)
:ets.insert(@members, {{gid, uid}, merged})
end
end
:ok
end
# Everything else (unknown events, events with no cache mapping) is a no-op.
def handle_dispatch(_name, _data, _config), do: :ok
# --- write helpers ------------------------------------------------------
defp put_me(user) when is_map(user),
do: :ets.insert(@me, {:me, Map.merge(existing(@me, :me), user)})
defp put_me(_), do: :ok
defp put_channel(%{"id" => id} = ch) when is_binary(id), do: :ets.insert(@channels, {id, ch})
defp put_channel(_), do: :ok
# A member's `user` object is moved into the users table; the member row keeps
# only a `"user_id"` back-reference. Merges over any existing row so partial
# updates (GUILD_MEMBER_UPDATE, MESSAGE_CREATE fragments) don't drop fields.
defp put_member(gid, %{"user" => %{"id" => uid} = user} = member) when is_binary(gid) do
upsert_user(user)
merged =
existing(@members, {gid, uid})
|> Map.merge(Map.delete(member, "user"))
|> Map.put("user_id", uid)
:ets.insert(@members, {{gid, uid}, merged})
end
defp put_member(_gid, _member), do: :ok
defp put_voice_state(gid, %{"user_id" => uid} = vs) when is_binary(uid),
do: :ets.insert(@voice_states, {{gid, uid}, vs})
defp put_voice_state(_gid, _vs), do: :ok
defp put_presence(gid, %{"user" => %{"id" => uid}} = presence) when is_binary(gid),
do: :ets.insert(@presences, {{gid, uid}, presence})
defp put_presence(_gid, _presence), do: :ok
defp upsert_user(%{"id" => uid} = user),
do: :ets.insert(@users, {uid, Map.merge(existing(@users, uid), user)})
defp upsert_user(_), do: :ok
defp existing(table, key) do
case :ets.lookup(table, key) do
[{_, v}] -> v
[] -> %{}
end
end
# --- read API -----------------------------------------------------------
@typedoc "A cached entity, as its original string-keyed payload map."
@type entity :: map()
@doc "The bot's own user object (from READY / USER_UPDATE)."
@spec me() :: {:ok, entity()} | :error
def me, do: fetch(@me, :me)
@doc "Bang variant of `me/0`; raises if unset."
@spec me!() :: entity()
def me!, do: unwrap(me(), :me)
@doc "A guild by id."
@spec guild(String.t()) :: {:ok, entity()} | :error
def guild(id), do: fetch(@guilds, id)
@doc "Bang variant of `guild/1`."
@spec guild!(String.t()) :: entity()
def guild!(id), do: unwrap(guild(id), id)
@doc "All cached guilds (including unavailable stubs)."
@spec guilds() :: [entity()]
def guilds, do: all_values(@guilds)
@doc "A channel (or thread, or DM) by id."
@spec channel(String.t()) :: {:ok, entity()} | :error
def channel(id), do: fetch(@channels, id)
@doc "Bang variant of `channel/1`."
@spec channel!(String.t()) :: entity()
def channel!(id), do: unwrap(channel(id), id)
@doc "All cached channels/threads belonging to a guild."
@spec channels(String.t()) :: [entity()]
def channels(guild_id) do
@channels
|> :ets.select([{{:_, %{"guild_id" => guild_id}}, [], [:"$_"]}])
|> Enum.map(&elem(&1, 1))
end
@doc "A guild member by guild id + user id."
@spec member(String.t(), String.t()) :: {:ok, entity()} | :error
def member(guild_id, user_id), do: fetch(@members, {guild_id, user_id})
@doc "Bang variant of `member/2`."
@spec member!(String.t(), String.t()) :: entity()
def member!(guild_id, user_id), do: unwrap(member(guild_id, user_id), {guild_id, user_id})
@doc "All cached members of a guild."
@spec members(String.t()) :: [entity()]
def members(guild_id), do: prefix_values(@members, guild_id)
@doc "A user by id."
@spec user(String.t()) :: {:ok, entity()} | :error
def user(id), do: fetch(@users, id)
@doc "Bang variant of `user/1`."
@spec user!(String.t()) :: entity()
def user!(id), do: unwrap(user(id), id)
@doc "A role by guild id + role id."
@spec role(String.t(), String.t()) :: {:ok, entity()} | :error
def role(guild_id, role_id), do: fetch(@roles, {guild_id, role_id})
@doc "Bang variant of `role/2`."
@spec role!(String.t(), String.t()) :: entity()
def role!(guild_id, role_id), do: unwrap(role(guild_id, role_id), {guild_id, role_id})
@doc "All cached roles of a guild."
@spec roles(String.t()) :: [entity()]
def roles(guild_id), do: prefix_values(@roles, guild_id)
@doc "A presence by guild id + user id (only populated when `cache_presences: true`)."
@spec presence(String.t(), String.t()) :: {:ok, entity()} | :error
def presence(guild_id, user_id), do: fetch(@presences, {guild_id, user_id})
@doc "Bang variant of `presence/2`."
@spec presence!(String.t(), String.t()) :: entity()
def presence!(guild_id, user_id), do: unwrap(presence(guild_id, user_id), {guild_id, user_id})
@doc "All cached presences of a guild."
@spec presences(String.t()) :: [entity()]
def presences(guild_id), do: prefix_values(@presences, guild_id)
@doc "A voice state by guild id + user id."
@spec voice_state(String.t(), String.t()) :: {:ok, entity()} | :error
def voice_state(guild_id, user_id), do: fetch(@voice_states, {guild_id, user_id})
@doc "Bang variant of `voice_state/2`."
@spec voice_state!(String.t(), String.t()) :: entity()
def voice_state!(guild_id, user_id),
do: unwrap(voice_state(guild_id, user_id), {guild_id, user_id})
@doc "All cached voice states of a guild."
@spec voice_states(String.t()) :: [entity()]
def voice_states(guild_id), do: prefix_values(@voice_states, guild_id)
# --- read helpers -------------------------------------------------------
defp cache_presences?(config), do: Map.get(config, :cache_presences, false)
defp fetch(table, key) do
case :ets.lookup(table, key) do
[{_, v}] -> {:ok, v}
[] -> :error
end
end
defp all_values(table), do: :ets.select(table, [{{:_, :"$1"}, [], [:"$1"]}])
# Per-guild listing of an ordered_set keyed by `{guild_id, x}`.
defp prefix_values(table, guild_id),
do: :ets.select(table, [{{{guild_id, :_}, :"$1"}, [], [:"$1"]}])
defp unwrap({:ok, value}, _key), do: value
defp unwrap(:error, key), do: raise("Dexcord.Cache: no cached entry for #{inspect(key)}")
end

View file

@ -10,6 +10,7 @@ defmodule Dexcord.Config do
""" """
@pt_key __MODULE__ @pt_key __MODULE__
@app_id_key {__MODULE__, :application_id}
@doc "Stores the validated config map." @doc "Stores the validated config map."
@spec put(map()) :: :ok @spec put(map()) :: :ok
@ -36,4 +37,17 @@ defmodule Dexcord.Config do
@doc "Returns the configured event handler module." @doc "Returns the configured event handler module."
@spec handler :: module() @spec handler :: module()
def handler, do: get(:handler) def handler, do: get(:handler)
@doc """
Caches the bot's application id (learned once by `Dexcord.Slash.Registrar`).
Stored under a dedicated `:persistent_term` key, separate from the config map,
so it can be written after boot without republishing the whole config.
"""
@spec put_application_id(String.t()) :: :ok
def put_application_id(id) when is_binary(id), do: :persistent_term.put(@app_id_key, id)
@doc "Returns the cached application id, or `nil` if not yet resolved."
@spec application_id() :: String.t() | nil
def application_id, do: :persistent_term.get(@app_id_key, nil)
end end

View file

@ -33,16 +33,60 @@ defmodule Dexcord.Dispatcher do
@impl true @impl true
def handle_cast({:dispatch, name, data}, state) do def handle_cast({:dispatch, name, data}, state) do
# TODO: CACHE - once Dexcord.Cache exists, call Cache.handle_dispatch(name, data) config = Dexcord.Config.get()
# INLINE HERE (before spawning the handler Task) so the cache reflects the
# event before the user's handler observes it. Ordered, single-writer.
handler = Dexcord.Config.handler() # Cache write happens INLINE, in this (single-writer) process, BEFORE the
# handler Task is spawned - so the handler always observes a cache that
# already reflects this event, and cache writes stay in exact gateway order.
Dexcord.Cache.handle_dispatch(name, data, config)
maybe_request_members(name, data, config)
maybe_route_slash(name, data, config)
Task.Supervisor.start_child(@task_supervisor, fn -> Task.Supervisor.start_child(@task_supervisor, fn ->
handler.handle_event({name, data}) config.handler.handle_event({name, data})
end) end)
{:noreply, state} {:noreply, state}
end end
# When `request_guild_members: true` and the :guild_members intent is set, ask
# the gateway (op 8) for the full member list of each real guild as it arrives.
# The GUILD_MEMBERS_CHUNK replies flow back through normal dispatch into the
# cache. Unavailable-stub GUILD_CREATEs are skipped (they carry no members).
defp maybe_request_members(:GUILD_CREATE, data, config) do
if config.request_guild_members and data["unavailable"] != true and
is_binary(data["id"]) and
Dexcord.Intents.enabled?(config.intents, :guild_members) do
Dexcord.Gateway.request_guild_members(data["id"],
query: "",
limit: 0,
presences: config.cache_presences
)
end
:ok
end
defp maybe_request_members(_name, _data, _config), do: :ok
# When a `slash:` module is configured, auto-route INTERACTION_CREATEs to it in
# a separate Task, IN ADDITION to the raw handler (which always receives the
# event). Only the interaction top-level `"type"`s handled by the slash layer are
# routed: 2 (application command), 3 (message component), 5 (modal submit).
# Type 1 (PING) never arrives over the gateway and type 4 (autocomplete) is left
# to the raw handler.
defp maybe_route_slash(:INTERACTION_CREATE, interaction, config) when is_map(interaction) do
slash_mod = Map.get(config, :slash)
if slash_mod && interaction["type"] in [2, 3, 5] do
Task.Supervisor.start_child(@task_supervisor, fn ->
Dexcord.Slash.dispatch(interaction, slash_mod)
end)
end
:ok
end
defp maybe_route_slash(_name, _data, _config), do: :ok
end end

View file

@ -14,21 +14,27 @@ defmodule Dexcord.Gateway do
RESUME* (`session_id`, `last_seq`, `resume_gateway_url`, backoff, fatal flag) RESUME* (`session_id`, `last_seq`, `resume_gateway_url`, backoff, fatal flag)
lives in `Dexcord.Session`'s ETS, not here. lives in `Dexcord.Session`'s ETS, not here.
## Phase 0-1 scope ## Reliability behaviour
This implements the happy path fully (connect -> HELLO -> IDENTIFY -> READY -> The full connect -> HELLO -> IDENTIFY/RESUME -> heartbeat -> dispatch path is
heartbeat with ACK/zombie tracking -> dispatch) plus basic implemented and hardened: replayed dispatches during `:resuming` are forwarded
disconnect -> backoff -> reconnect, and the real Mint/heartbeat/Session in order and re-arm the resume watchdog on every frame (so a large replay
plumbing. The resume protocol structure is present; its edge cases (replay backlog can't trip it, while true silence still does), op 9 is handled for
ordering guarantees, op 9 timing, resume-loop cap, stale-socket guard) are both `d: true` (resumable, RESUME again) and `d: false` (clear session,
hardened in Phase 2. jittered 1-5s wait, fresh IDENTIFY), a resume-loop cap abandons resuming for a
fresh IDENTIFY after too many consecutive failures, heartbeat ACK tracking
detects a zombie connection (HEARTBEAT sent, no ACK before the next beat) and
closes with 4000 to force a resume, and a `conn_gen` guard drops stale
messages from a socket that has already been superseded by a reconnect. See
`close_action/1` in `Dexcord.Gateway.Payload` for the fatal vs. reidentify vs.
resume close-code table.
## `gateway_url:` override ## `gateway_url:` override
If config carries `:gateway_url` (e.g. `"ws://127.0.0.1:4000"`), that URL is used If config carries `:gateway_url` (e.g. `"ws://127.0.0.1:4000"`), that URL is used
directly and the `GET /gateway/bot` call is skipped. Plain `ws://` maps to an directly and the `GET /gateway/bot` call is skipped. Plain `ws://` maps to an
`:http` Mint scheme (TCP, no TLS); `wss://` to `:https`. This is what the Phase 2 `:http` Mint scheme (TCP, no TLS); `wss://` to `:https`. This is what the
FakeGateway test suite connects against. `Dexcord.FakeGateway` test suite connects against.
""" """
@behaviour :gen_statem @behaviour :gen_statem
@ -107,16 +113,49 @@ defmodule Dexcord.Gateway do
ref: nil, ref: nil,
status: nil, status: nil,
resp_headers: [], resp_headers: [],
# Bytes that ride in coalesced with the upgrade's 101 response (before the
# Mint websocket exists). Buffered here and decoded the instant the
# websocket is built - see process_responses/3. Reset per connection.
pending_data: <<>>,
hb_interval: nil, hb_interval: nil,
hb_acked?: true, hb_acked?: true,
# Set on op 9 `d:false` in :resuming; while true the :resuming op 0 handler # Set on op 9 `d:false` in :resuming; while true the :resuming op 0 handler
# must not re-arm the resume watchdog (it would clobber the pending # must not re-arm the resume watchdog (it would clobber the pending
# :reidentify/:identify timer). Cleared on a fresh connection and once we # :reidentify/:identify timer). Cleared on a fresh connection and once we
# actually IDENTIFY. See handle_payload/3 op 9 + op 0 in :resuming. # actually IDENTIFY. See handle_payload/3 op 9 + op 0 in :resuming.
#
# INVARIANT (keep this tight or the statem can wedge in :resuming with a
# cleared session forever):
# * Every handler that arms a `:state_timeout` while in :resuming MUST
# consult this flag before doing so - a replayed dispatch trickling in
# during the reidentify wait must NOT re-arm the @resume_timeout
# watchdog, or it cancels the pending :reidentify/:identify timer. See
# the `data.reidentify_pending?` branch of the op-0 `:resuming` cond.
# * Every do_identify/1 deferral path taken in :resuming (the residual
# identify-gap wait) runs with this flag still set; it is only cleared
# once send_identify/1 actually transitions us out of :resuming.
# * Also DO NOT bump `last_seq` from frames handled under this flag: that
# session is abandoned, so its sequence numbers are meaningless now.
reidentify_pending?: false, reidentify_pending?: false,
# One-shot floor (ms) for the next reconnect delay, e.g. the mandated # One-shot floor (ms) for the next reconnect delay, e.g. the mandated
# 1-5s wait after an op 9 invalid-session. Consumed on :disconnected enter. # 1-5s wait after an op 9 invalid-session. Consumed on :disconnected enter.
reconnect_after_ms: 0 reconnect_after_ms: 0,
# Gateway send budget (token bucket). Discord allows 120 sends / 60s
# INCLUDING heartbeats; heartbeats bypass this bucket entirely (they use
# the reserved remainder), while op-3 presence updates and op-8 member
# requests each consume one token. When empty they're queued (bounded)
# and drained on `{:timeout, :send_refill}` ticks as tokens refill.
#
# The queue persists across reconnects (it lives in statem data, which a
# RESUME keeps): a frame that could not be sent is requeued at the front
# and drained once :connected is re-entered. One consequence is op-8
# staleness - a queued GUILD_MEMBERS_CHUNK request may, after a reconnect,
# target a guild the bot has since left. That is harmless: Discord simply
# ignores an op-8 for a guild it no longer shares, and the request is
# idempotent, so no cleanup of the queue across reconnects is needed.
send_tokens: send_budget_max() * 1.0,
send_last_refill: System.monotonic_time(:millisecond),
send_queue: :queue.new()
} }
{:ok, :disconnected, data} {:ok, :disconnected, data}
@ -153,6 +192,7 @@ defmodule Dexcord.Gateway do
data data
| status: nil, | status: nil,
resp_headers: [], resp_headers: [],
pending_data: <<>>,
websocket: nil, websocket: nil,
reidentify_pending?: false reidentify_pending?: false
} }
@ -241,6 +281,20 @@ defmodule Dexcord.Gateway do
end end
end end
# --- send-budget drain (named generic timeout, survives transitions) ----
# Tokens have refilled: drain as many queued op-3/op-8 frames as the budget now
# allows. Only fires usefully while :connected (the socket is live there); a tick
# arriving in any other state is dropped - the queue persists and :connected's
# state-enter re-arms a drain once the socket is back.
def handle_event({:timeout, :send_refill}, :drain, :connected, data) do
drain_queue(data)
end
def handle_event({:timeout, :send_refill}, :drain, _state, _data) do
:keep_state_and_data
end
# --- shared: inbound socket traffic (mode: :active -> :info) ------------ # --- shared: inbound socket traffic (mode: :active -> :info) ------------
def handle_event(:info, msg, _state, data) do def handle_event(:info, msg, _state, data) do
@ -288,21 +342,31 @@ defmodule Dexcord.Gateway do
# --- casts -------------------------------------------------------------- # --- casts --------------------------------------------------------------
def handle_event(:cast, {:update_presence, presence}, :connected, data) do def handle_event(:cast, {:update_presence, presence}, state, data) do
send_and_keep(data, Payload.presence_update(presence)) cast_budgeted(state, data, Payload.presence_update(presence))
end end
def handle_event(:cast, {:request_guild_members, guild_id, opts}, :connected, data) do def handle_event(:cast, {:request_guild_members, guild_id, opts}, state, data) do
send_and_keep(data, Payload.request_guild_members(guild_id, opts)) cast_budgeted(state, data, Payload.request_guild_members(guild_id, opts))
end end
def handle_event(:cast, _msg, _state, _data) do def handle_event(:cast, _msg, _state, _data) do
# Presence/member requests while not connected are dropped this phase.
:keep_state_and_data :keep_state_and_data
end end
# --- catch-all: ignore stray events, never crash the statem ------------- # --- catch-all: ignore stray events, never crash the statem -------------
# On (re)entering :connected, kick a drain if frames were queued while the
# budget was exhausted or the socket was down. A zero-delay tick lets
# drain_queue/1 send what the refilled budget now allows and re-arm precisely.
def handle_event(:enter, _old, :connected, data) do
if :queue.is_empty(data.send_queue) do
:keep_state_and_data
else
{:keep_state_and_data, [{{:timeout, :send_refill}, 0, :drain}]}
end
end
def handle_event(:enter, _old, _state, _data), do: :keep_state_and_data def handle_event(:enter, _old, _state, _data), do: :keep_state_and_data
def handle_event(type, content, state, _data) do def handle_event(type, content, state, _data) do
@ -433,8 +497,11 @@ defmodule Dexcord.Gateway do
# identify gap) before IDENTIFYing on this socket. A dispatch trickling in # identify gap) before IDENTIFYing on this socket. A dispatch trickling in
# now must NOT re-arm the resume watchdog: doing so would cancel the pending # now must NOT re-arm the resume watchdog: doing so would cancel the pending
# :reidentify/:identify state_timeout and wedge us in :resuming with a cleared # :reidentify/:identify state_timeout and wedge us in :resuming with a cleared
# session forever. Dispatch it and leave the timers untouched. # session forever. Dispatch it and leave the timers untouched. We also do NOT
bump_and_dispatch(payload) # bump last_seq here: this session was abandoned on op 9 d:false (its state was
# cleared, last_seq reset to nil), so a trickled frame's sequence must not
# repopulate it and cross into the fresh session about to be IDENTIFYed.
dispatch_only(payload)
{:keep_state, data} {:keep_state, data}
true -> true ->
@ -460,8 +527,30 @@ defmodule Dexcord.Gateway do
_ -> :ok _ -> :ok
end end
name = Dexcord.EventNames.to_atom(payload["t"]) dispatch_only(payload)
Dexcord.Dispatcher.dispatch(name, payload["d"]) end
# Dispatch an op-0 frame without touching last_seq (used for frames from an
# abandoned session during the reidentify wait). Tolerant of a non-binary `t`:
# Discord's `t` is a string on real dispatches, but a malformed/garbage op-0 frame
# (e.g. `"t": null`) must not crash the statem via EventNames.to_atom/1's
# is_binary-guarded clause. We treat a non-binary `t` as unrecognised protocol
# noise: log at debug and drop it rather than mint a bogus event or dispatch
# `nil` data to user handlers. Handled here (at the gateway altitude) on purpose,
# not by loosening EventNames, so the garbage is logged, not silently absorbed.
defp dispatch_only(payload) do
case payload["t"] do
t when is_binary(t) ->
Dexcord.Dispatcher.dispatch(Dexcord.EventNames.to_atom(t), payload["d"])
other ->
Logger.debug(
"Dexcord.Gateway op-0 frame with non-binary t=#{inspect(other)}; " <>
"treating as protocol noise and not dispatching"
)
:ok
end
end end
# --- close handling ----------------------------------------------------- # --- close handling -----------------------------------------------------
@ -610,11 +699,28 @@ defmodule Dexcord.Gateway do
{:headers, ref, headers} when ref == data.ref -> {:headers, ref, headers} when ref == data.ref ->
process_responses(%{data | resp_headers: data.resp_headers ++ headers}, rest, actions) process_responses(%{data | resp_headers: data.resp_headers ++ headers}, rest, actions)
# A 101 upgrade is delivered by Mint as `:single`-body: the trailing bytes in
# the same TCP segment arrive as a `{:data, ref, bin}` response ORDERED BEFORE
# the `{:done, ref}` that lets us build the websocket. The server's first frame
# (Discord's HELLO) is routinely packed into that segment, so we must buffer
# these pre-upgrade bytes rather than drop them; they're decoded in the
# `{:done, ...}` clause below the instant the websocket exists.
{:data, ref, bin} when ref == data.ref and data.websocket == nil ->
process_responses(%{data | pending_data: data.pending_data <> bin}, rest, actions)
{:done, ref} when ref == data.ref and data.websocket == nil -> {:done, ref} when ref == data.ref and data.websocket == nil ->
case Mint.WebSocket.new(data.conn, ref, data.status, data.resp_headers) do case Mint.WebSocket.new(data.conn, ref, data.status, data.resp_headers) do
{:ok, conn, websocket} -> {:ok, conn, websocket} ->
data = %{data | conn: conn, websocket: websocket} data = %{data | conn: conn, websocket: websocket}
process_responses(data, rest, [{:next_event, :internal, :upgraded} | actions]) upgrade_action = {:next_event, :internal, :upgraded}
case decode_pending(data) do
{:ok, data, frame_actions} ->
process_responses(data, rest, frame_actions ++ [upgrade_action | actions])
{:error, data, reason} ->
{:error, data, reason}
end
{:error, conn, reason} -> {:error, conn, reason} ->
{:error, %{data | conn: conn}, reason} {:error, %{data | conn: conn}, reason}
@ -642,6 +748,26 @@ defmodule Dexcord.Gateway do
end end
end end
# Decode any bytes buffered during the upgrade (a HELLO coalesced with the 101)
# now that the websocket exists, emitting them as internal `{:frame, _}` events in
# order. Returns the actions reversed to match process_responses/3's accumulator.
defp decode_pending(%{pending_data: <<>>} = data), do: {:ok, data, []}
defp decode_pending(%{pending_data: buffered} = data) do
case Mint.WebSocket.decode(data.websocket, buffered) do
{:ok, websocket, frames} ->
actions =
frames
|> Enum.map(&{:next_event, :internal, {:frame, &1}})
|> Enum.reverse()
{:ok, %{data | websocket: websocket, pending_data: <<>>}, actions}
{:error, websocket, reason} ->
{:error, %{data | websocket: websocket, pending_data: <<>>}, reason}
end
end
# Sends a websocket frame, threading the Mint structs. Never crashes. # Sends a websocket frame, threading the Mint structs. Never crashes.
defp send_frame(%{websocket: nil} = data, _frame), do: {:error, data, :no_websocket} defp send_frame(%{websocket: nil} = data, _frame), do: {:error, data, :no_websocket}
@ -666,13 +792,139 @@ defmodule Dexcord.Gateway do
end end
end end
defp send_and_keep(data, frame_map) do # --- gateway send budget (token bucket) ---------------------------------
case send_frame(data, {:text, JSON.encode!(frame_map)}) do
{:ok, data} -> {:keep_state, data} # Send a budgeted (op-3/op-8) frame if a token is available, else queue it. The
{:error, data, _reason} -> fail_reconnect(data) # bucket refills continuously (lazy, monotonic-clock based) so no timer runs
# while the budget is healthy; a refill tick is only armed when we actually have
# to wait for the next token.
# Route an op-3/op-8 cast according to the connection state.
#
# * :connected - spend a token and send now (queue-first, FIFO).
# * any other lifecycle enqueue (bounded) instead of dropping. The frame
# state (:connecting/ survives the handshake or resume and drains on the
# :hello_wait/ next `:connected` enter. This is what stops a
# :identifying/:resuming) request_guild_members issued while GUILD_CREATEs are
# still replaying in :resuming from being lost.
# * :disconnected - enqueue only if a session still exists (a transient
# reconnect that will RESUME and drain); otherwise drop.
# With no session there is no socket and no imminent
# :connected to drain into, so queueing would just
# accumulate frames for a connection that may never come.
defp cast_budgeted(:connected, data, frame_map), do: queue_or_send(data, frame_map)
defp cast_budgeted(:disconnected, data, frame_map) do
if Session.session_id() do
{:keep_state, enqueue(data, frame_map)}
else
:keep_state_and_data
end end
end end
defp cast_budgeted(_state, data, frame_map), do: {:keep_state, enqueue(data, frame_map)}
defp queue_or_send(data, frame_map) do
data = refill(data)
# Queue-first discipline: only send immediately when NOTHING is already waiting.
# If the queue is non-empty, this frame must fall in behind the older ones (FIFO)
# - jumping ahead would let a newer op-3 presence overwrite an older queued one
# out of order (Discord is last-write-wins), or reorder op-8 requests. So we
# enqueue at the back and drain from the front for exactly as many tokens as we
# have; the empty-queue fast path preserves the no-timer, send-now behaviour when
# the budget is healthy.
if :queue.is_empty(data.send_queue) and data.send_tokens >= 1.0 do
case send_one_budgeted(data, frame_map) do
{:ok, data} -> {:keep_state, data}
{:error, data} -> fail_reconnect(data)
end
else
do_drain(enqueue(data, frame_map))
end
end
# Drain queued frames greedily up to the current token balance, then re-arm a
# refill tick if the queue is still backed up.
defp drain_queue(data), do: do_drain(refill(data))
defp do_drain(data) do
cond do
:queue.is_empty(data.send_queue) ->
{:keep_state, data}
data.send_tokens >= 1.0 ->
{{:value, frame_map}, rest} = :queue.out(data.send_queue)
case send_one_budgeted(%{data | send_queue: rest}, frame_map) do
{:ok, data} -> do_drain(data)
{:error, data} -> fail_reconnect(data)
end
true ->
{:keep_state, data, [refill_action(data)]}
end
end
# Spend one token and send a single budgeted (op-3/op-8) frame. The one place both
# the send-now and drain paths encode+send a budgeted frame, so the spend/refund
# and requeue-on-failure accounting lives here once.
#
# * success -> `{:ok, data}` with a token spent.
# * failure -> `{:error, data}`: the token is REFUNDED (the frame never left, so
# charging it would leak budget over time) and the frame is requeued at the
# FRONT so it isn't lost; the caller then fail_reconnects and the drain re-sends
# it once :connected returns.
defp send_one_budgeted(data, frame_map) do
data = %{data | send_tokens: data.send_tokens - 1.0}
case send_frame(data, {:text, JSON.encode!(frame_map)}) do
{:ok, data} ->
{:ok, data}
{:error, data, reason} ->
Logger.warning("Dexcord.Gateway budgeted send failed: #{inspect(reason)}")
data = %{
data
| send_tokens: min(data.send_tokens + 1.0, send_budget_max() * 1.0),
send_queue: :queue.in_r(frame_map, data.send_queue)
}
{:error, data}
end
end
defp enqueue(data, frame_map) do
if :queue.len(data.send_queue) >= send_queue_max() do
Logger.warning(
"Dexcord.Gateway send-budget queue full (#{send_queue_max()}); " <>
"dropping op #{frame_map["op"]} frame"
)
data
else
%{data | send_queue: :queue.in(frame_map, data.send_queue)}
end
end
# Continuous token refill based on wall time since the last touch, capped at the
# bucket size. Reads config each call so tests can shrink the budget live.
defp refill(data) do
{max, window} = send_budget()
now = System.monotonic_time(:millisecond)
elapsed = now - data.send_last_refill
tokens = min(max * 1.0, data.send_tokens + elapsed * (max / window))
%{data | send_tokens: tokens, send_last_refill: now}
end
# Named generic timeout for when the next whole token becomes available (only
# armed when tokens < 1, so the delay is always positive).
defp refill_action(data) do
{max, window} = send_budget()
ms = max(1, ceil((1.0 - data.send_tokens) / (max / window)))
{{:timeout, :send_refill}, ms, :drain}
end
# --- identify / resume / heartbeat sends -------------------------------- # --- identify / resume / heartbeat sends --------------------------------
# Enforce the identify gap on EVERY identify path. If a gap is still pending # Enforce the identify gap on EVERY identify path. If a gap is still pending
@ -776,6 +1028,16 @@ defmodule Dexcord.Gateway do
defp max_resume_attempts, do: Application.get_env(:dexcord, :max_resume_attempts, 5) defp max_resume_attempts, do: Application.get_env(:dexcord, :max_resume_attempts, 5)
# Send budget `{tokens, window_ms}`. Default `{115, 60_000}`: Discord's real
# limit is 120 sends / 60s including heartbeats, so ~5 tokens are reserved for
# heartbeats (which bypass this bucket) and the remaining 115 fund op-3/op-8.
defp send_budget, do: Application.get_env(:dexcord, :send_budget, {115, 60_000})
defp send_budget_max, do: elem(send_budget(), 0)
# Max frames held while the budget is exhausted; further arrivals are dropped
# (with a warning) until the queue drains.
defp send_queue_max, do: Application.get_env(:dexcord, :send_queue_max, 128)
@doc false @doc false
# Reconnect backoff: min(base * 2^attempt, cap) with +-20% jitter; 0 on the # Reconnect backoff: min(base * 2^attempt, cap) with +-20% jitter; 0 on the
# first attempt. `:backoff` config `{base_ms, cap_ms}` defaults to `{1000, 60000}`. # first attempt. `:backoff` config `{base_ms, cap_ms}` defaults to `{1000, 60000}`.

148
lib/dexcord/prefix.ex Normal file
View file

@ -0,0 +1,148 @@
defmodule Dexcord.Prefix do
@moduledoc """
A textual (prefix) command router.
`parse/2` is a pure function that recognises a prefixed command and splits it
into a command word plus its argument string / argument list. `dispatch/2`
wires a raw `MESSAGE_CREATE` payload through `parse/2` into a
`Dexcord.Prefix.Router` module, skipping messages authored by bots (including
the bot itself). A router `use`s `Dexcord.Prefix.Router` and defines
`handle_command/3` clauses for the commands it cares about; an `:ignore`
catch-all is injected for everything else.
defmodule MyBot.Commands do
use Dexcord.Prefix.Router
def handle_command("ping", _args, msg),
do: Dexcord.Api.create_message(msg["channel_id"], "pong")
end
# in the handler:
def handle_event({:MESSAGE_CREATE, msg}),
do: Dexcord.Prefix.dispatch(msg, prefix: "!", to: MyBot.Commands)
"""
@doc """
Parses `content` as a `prefix`-prefixed command.
Returns `{:ok, command, arg_string, args}` where `command` is the first word
after the prefix, `arg_string` is the trimmed remainder, and `args` is that
remainder split on whitespace. Returns `:nomatch` when `content` does not start
with `prefix`, when there is no command word after the prefix, or when either
argument is not a binary.
Pure: no cache reads, no bot-author check (that lives in `dispatch/2`).
## Examples
iex> Dexcord.Prefix.parse("!echo hello world", "!")
{:ok, "echo", "hello world", ["hello", "world"]}
iex> Dexcord.Prefix.parse("!ping", "!")
{:ok, "ping", "", []}
iex> Dexcord.Prefix.parse("hello", "!")
:nomatch
"""
@spec parse(binary(), binary()) ::
{:ok, String.t(), String.t(), [String.t()]} | :nomatch
def parse(content, prefix)
when is_binary(content) and is_binary(prefix) and prefix != "" do
if String.starts_with?(content, prefix) do
content
|> binary_part(byte_size(prefix), byte_size(content) - byte_size(prefix))
|> String.trim_leading()
|> split_command()
else
:nomatch
end
end
def parse(_content, _prefix), do: :nomatch
# The prefix has already been stripped and any leading whitespace trimmed.
defp split_command(rest) do
case String.split(rest, ~r/\s+/, parts: 2) do
# Prefix with nothing (or only whitespace) after it: not a command.
[""] ->
:nomatch
[command] ->
{:ok, command, "", []}
[command, arg_string] ->
arg_string = String.trim_trailing(arg_string)
{:ok, command, arg_string, String.split(arg_string, ~r/\s+/, trim: true)}
end
end
@doc """
Routes a raw `MESSAGE_CREATE` payload to a `Dexcord.Prefix.Router`.
Options:
* `:prefix` (required) - the command prefix, e.g. `"!"`
* `:to` (required) - the router module
Messages authored by a bot are skipped and return `:ignore`. "Authored by a
bot" means either the message's `author.bot` flag is `true`, or the author id
matches the cached bot user (`Dexcord.Cache.me/0`) - this self-check catches the
bot's own messages even on the rare payloads that omit the `bot` flag, and
prevents a command that replies in-channel from recursively triggering itself.
On a match the router's `handle_command(command, args, msg)` is called and its
result returned; a non-match (or a skipped bot message) returns `:ignore`.
"""
@spec dispatch(map(), keyword()) :: any()
def dispatch(msg, opts) when is_map(msg) do
prefix = Keyword.fetch!(opts, :prefix)
router = Keyword.fetch!(opts, :to)
if bot_author?(msg) do
:ignore
else
case parse(msg["content"] || "", prefix) do
{:ok, command, _arg_string, args} -> router.handle_command(command, args, msg)
:nomatch -> :ignore
end
end
end
defp bot_author?(msg) do
author = msg["author"] || %{}
author["bot"] == true or own_message?(author)
end
defp own_message?(author) do
case Dexcord.Cache.me() do
{:ok, %{"id" => id}} -> is_binary(id) and author["id"] == id
_ -> false
end
end
end
defmodule Dexcord.Prefix.Router do
@moduledoc """
Behaviour for a prefix command router.
`use Dexcord.Prefix.Router` declares the behaviour and injects an `:ignore`
catch-all `handle_command/3`, so a router only writes the command clauses it
wants. `handle_command/3` receives the command word, the whitespace-split
argument list, and the raw `MESSAGE_CREATE` map.
"""
@callback handle_command(command :: String.t(), args :: [String.t()], msg :: map()) :: any()
defmacro __using__(_opts) do
quote do
@behaviour Dexcord.Prefix.Router
@before_compile Dexcord.Prefix.Router
end
end
defmacro __before_compile__(_env) do
quote do
def handle_command(_command, _args, _msg), do: :ignore
end
end
end

View file

@ -94,11 +94,20 @@ defmodule Dexcord.Session do
@doc """ @doc """
Records a freshly-established session from a READY payload. Records a freshly-established session from a READY payload.
Resets `last_seq` to `nil` so a new session never inherits the previous
session's sequence number. Sequence numbers are per-session; carrying a stale
`last_seq` across a session boundary would let a later RESUME (or heartbeat)
reference a sequence that belongs to an abandoned session. READY's own sequence
number bumps `last_seq` right after this call (`bump_and_dispatch/1` runs
immediately after `establish/2` in the gateway's READY handler), so the fresh
session is seeded from its own first frame.
""" """
@spec establish(String.t(), String.t()) :: :ok @spec establish(String.t(), String.t()) :: :ok
def establish(session_id, resume_gateway_url) do def establish(session_id, resume_gateway_url) do
put(:session_id, session_id) put(:session_id, session_id)
put(:resume_gateway_url, resume_gateway_url) put(:resume_gateway_url, resume_gateway_url)
put(:last_seq, nil)
:ok :ok
end end

256
lib/dexcord/slash.ex Normal file
View file

@ -0,0 +1,256 @@
defmodule Dexcord.Slash do
@moduledoc """
Slash (application) command behaviour, routing, and response helpers.
A bot's slash module `use`s `Dexcord.Slash` and defines:
* `commands/0` - the list of command-definition maps registered at startup by
`Dexcord.Slash.Registrar` (see below)
* `handle_interaction/2` clauses per command name (application commands)
* optionally `handle_component/2` and `handle_modal/2` clauses, keyed on the
interaction's `custom_id`, for message-component and modal-submit interactions
`use Dexcord.Slash` injects catch-all clauses for all three callbacks: the
`handle_interaction/2` fallback logs a **warning** (an application command the
module declared but does not handle is unexpected), while the
`handle_component/2` and `handle_modal/2` fallbacks only log at **debug**
(components and modals are commonly handled elsewhere, so an unmatched
`custom_id` is routine, not an error).
defmodule MyBot.Slash do
use Dexcord.Slash
def commands, do: [%{name: "ping", description: "Pong!"}]
def handle_interaction("ping", itx), do: Dexcord.Slash.respond(itx, "pong")
# message component (button / select) - 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
`dispatch/2` routes a raw `INTERACTION_CREATE` payload on its **top-level**
`interaction["type"]`: type 2 (application command) `handle_interaction/2`
keyed on `interaction["data"]["name"]`; type 3 (message component)
`handle_component/2` keyed on `interaction["data"]["custom_id"]`; type 5 (modal
submit) `handle_modal/2` keyed on `interaction["data"]["custom_id"]`. The
`Dexcord.Dispatcher` calls it automatically for those types when a `slash:`
module is configured (the raw event still reaches the handler).
## Response helpers
All four helpers go through `Dexcord.Api`. Payloads stay string-keyed on the
wire; the `data` maps you pass may use atom **or** string keys for
`content`/`embeds`/`components`, and `ephemeral: true` is translated to the
message `flags` bit (64).
* `respond/2` - an immediate CHANNEL_MESSAGE_WITH_SOURCE (type 4)
* `respond_later/1` - a DEFERRED response (type 5); "thinking..." while you work
* `followup/2` - a followup message after a deferred/initial response
* `edit_response/2` - edit the original (deferred or immediate) response
"""
@doc "The command-definition maps to register for this module."
@callback commands() :: [map()]
@doc "Handles a routed application-command interaction (type 2) for command `name`."
@callback handle_interaction(name :: String.t(), interaction :: map()) :: any()
@doc "Handles a routed message-component interaction (type 3) for `custom_id`."
@callback handle_component(custom_id :: String.t() | nil, interaction :: map()) :: any()
@doc "Handles a routed modal-submit interaction (type 5) for `custom_id`."
@callback handle_modal(custom_id :: String.t() | nil, interaction :: map()) :: any()
# Only `commands/0` and `handle_interaction/2` are required; a module that never
# uses components or modals need not define those callbacks (the injected
# defaults below satisfy the behaviour).
@optional_callbacks handle_component: 2, handle_modal: 2
defmacro __using__(_opts) do
quote do
@behaviour Dexcord.Slash
@before_compile Dexcord.Slash
end
end
defmacro __before_compile__(_env) do
quote do
require Logger
def handle_interaction(name, _interaction) do
Logger.warning(
"#{inspect(__MODULE__)} has no handle_interaction/2 clause for command " <>
"#{inspect(name)}; ignoring the interaction"
)
:ignore
end
def handle_component(custom_id, _interaction) do
Logger.debug(
"#{inspect(__MODULE__)} has no handle_component/2 clause for custom_id " <>
"#{inspect(custom_id)}; ignoring the component interaction"
)
:ignore
end
def handle_modal(custom_id, _interaction) do
Logger.debug(
"#{inspect(__MODULE__)} has no handle_modal/2 clause for custom_id " <>
"#{inspect(custom_id)}; ignoring the modal interaction"
)
:ignore
end
end
end
# --- routing ------------------------------------------------------------
@doc """
Routes an `INTERACTION_CREATE` payload to `mod` on its top-level `"type"`.
* type 2 (application command) `mod.handle_interaction/2`, keyed on
`interaction["data"]["name"]`
* type 3 (message component) `mod.handle_component/2`, keyed on
`interaction["data"]["custom_id"]`
* type 5 (modal submit) `mod.handle_modal/2`, keyed on
`interaction["data"]["custom_id"]`
Any other (or missing) type is ignored - the `Dexcord.Dispatcher` only routes
types 2/3/5 here, so this is defensive.
"""
@spec dispatch(map(), module()) :: any()
def dispatch(interaction, mod) when is_map(interaction) and is_atom(mod) do
case interaction["type"] do
2 -> mod.handle_interaction(get_in(interaction, ["data", "name"]), interaction)
3 -> mod.handle_component(get_in(interaction, ["data", "custom_id"]), interaction)
5 -> mod.handle_modal(get_in(interaction, ["data", "custom_id"]), interaction)
_ -> :ignore
end
end
# --- response helpers ---------------------------------------------------
@doc """
Sends an immediate response (type 4) to `interaction`.
`text_or_map` is either a binary (used as `content`) or a map supporting
`content`, `embeds`, `components`, and `ephemeral: true`.
"""
@spec respond(map(), String.t() | map()) :: {:ok, map()} | {:ok, nil} | {:error, term()}
def respond(interaction, content) when is_binary(content) do
respond(interaction, %{content: content})
end
def respond(interaction, %{} = data) do
Dexcord.Api.create_interaction_response(
interaction["id"],
interaction["token"],
%{"type" => 4, "data" => message_data(data)}
)
end
@doc """
Sends a deferred response (type 5) - shows a loading state while you prepare a
followup or edit the original response.
"""
@spec respond_later(map()) :: {:ok, map()} | {:ok, nil} | {:error, term()}
def respond_later(interaction) do
Dexcord.Api.create_interaction_response(
interaction["id"],
interaction["token"],
%{"type" => 5}
)
end
@doc """
Sends a followup message for `interaction`.
`text_or_map` is a binary (used as `content`) or a map as in `respond/2`.
"""
@spec followup(map(), String.t() | map()) :: {:ok, map()} | {:error, term()}
def followup(interaction, content) when is_binary(content) do
followup(interaction, %{content: content})
end
def followup(interaction, %{} = data) do
Dexcord.Api.create_followup_message(
interaction["application_id"],
interaction["token"],
message_data(data)
)
end
@doc """
Edits the original response for `interaction`.
`text_or_map` is a binary (used as `content`) or a map as in `respond/2`.
"""
@spec edit_response(map(), String.t() | map()) :: {:ok, map()} | {:error, term()}
def edit_response(interaction, content) when is_binary(content) do
edit_response(interaction, %{content: content})
end
def edit_response(interaction, %{} = data) do
Dexcord.Api.edit_original_interaction_response(
interaction["application_id"],
interaction["token"],
message_data(data)
)
end
# Builds the string-keyed interaction message-data map from a caller map that
# may use atom or string keys. Only recognised fields are copied through.
# `ephemeral: true` contributes the ephemeral `flags` bit (64), and a
# caller-supplied integer `:flags`/`"flags"` is preserved and OR-ed with it, so
# (e.g.) `flags: 4` passes through as `4` and `flags: 4, ephemeral: true` as `68`.
defp message_data(data) do
base =
Enum.reduce([:content, :embeds, :components], %{}, fn field, acc ->
case fetch_any(data, field) do
{:ok, value} -> Map.put(acc, Atom.to_string(field), value)
:error -> acc
end
end)
case flags(data) do
nil -> base
value -> Map.put(base, "flags", value)
end
end
# Combines a caller-supplied integer `flags` with the ephemeral bit (64).
# Returns nil when neither is present so the key is omitted entirely.
defp flags(data) do
caller =
case fetch_any(data, :flags) do
{:ok, value} when is_integer(value) -> value
_ -> nil
end
ephemeral = if truthy?(fetch_any(data, :ephemeral)), do: 64, else: nil
case {caller, ephemeral} do
{nil, nil} -> nil
{nil, bit} -> bit
{value, nil} -> value
{value, bit} -> Bitwise.bor(value, bit)
end
end
defp fetch_any(map, key) do
cond do
Map.has_key?(map, key) -> {:ok, Map.fetch!(map, key)}
Map.has_key?(map, Atom.to_string(key)) -> {:ok, Map.fetch!(map, Atom.to_string(key))}
true -> :error
end
end
defp truthy?({:ok, value}) when value not in [nil, false], do: true
defp truthy?(_), do: false
end

View file

@ -0,0 +1,180 @@
defmodule Dexcord.Slash.Registrar do
@moduledoc """
Startup Task that registers a bot's slash commands via REST.
Added to the supervision tree (right before `Dexcord.Gateway`) **only** when a
`slash:` module is configured, with `restart: :temporary`. It runs REST-only and
independently of the gateway - registration never touches or cycles the gateway
connection.
On start it:
1. calls `GET /oauth2/applications/@me` to learn the application id and caches
it via `Dexcord.Config.put_application_id/1` (a dedicated `:persistent_term`
key, so any process can read it back with `Dexcord.Config.application_id/0`);
2. bulk-overwrites the module's `commands/0`:
* **guild mode** - when `slash_guild_ids:` is a non-empty list, overwrites
each guild's commands (instant propagation, ideal for development). It
**never** touches global commands: an empty global overwrite would wipe a
bot's real (production) commands, so guild mode logs a hint instead.
* **global mode** - otherwise overwrites the application's global commands
(~1h propagation). As a symmetric footgun guard, an **empty** command list
in global mode is skipped (it would wipe every production command); the
Registrar logs how to do that on purpose instead.
## Failure behaviour
The Registrar owns its own retry policy rather than leaning on supervisor
restarts. It is `restart: :temporary`, so the supervisor never restarts it: a
registration failure can therefore NEVER cycle the gateway (or share the tree's
`max_restarts` budget with it - a burst of fast 4xx failures must not terminate
a healthy session). Instead, on failure it retries in-process a bounded number
of times with increasing back-off (default `2s` then `10s`), and if every
attempt fails it logs a loud `Logger.error` and exits `:normal`. The retry
delays are configurable via `config :dexcord, :registrar_retry_delays, [..]`
(a list of millisecond sleeps between attempts; its length + 1 is the attempt
count).
"""
require Logger
@default_retry_delays [2_000, 10_000]
@doc false
def child_spec(config) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [config]},
restart: :temporary,
type: :worker
}
end
@doc false
def start_link(config) do
Task.start_link(fn -> run(config) end)
end
@doc """
Runs the registration synchronously, with the bounded in-process retry loop.
Public so tests can drive it directly. Always returns `:ok` (exiting `:normal`
when the Task wraps it): registration failure is contained here and never
propagated as an abnormal exit, so it can never cycle the gateway.
"""
@spec run(map()) :: :ok
def run(config) do
attempt(config, 1, retry_delays())
end
# One registration attempt; on failure either sleeps + retries or gives up loud.
defp attempt(config, n, delays) do
case try_register(config) do
:ok ->
:ok
{:error, message} ->
case Enum.at(delays, n - 1) do
nil ->
Logger.error(
"Dexcord.Slash.Registrar: giving up after #{n} attempt(s) - #{message}. " <>
"Slash commands were NOT registered; the gateway is unaffected. Fix the cause " <>
"and restart the bot to retry."
)
:ok
delay ->
Logger.warning(
"Dexcord.Slash.Registrar: attempt #{n} failed - #{message}. " <>
"Retrying in #{delay}ms."
)
Process.sleep(delay)
attempt(config, n + 1, delays)
end
end
end
# A single end-to-end registration: resolve the app id, then overwrite commands.
# Returns :ok | {:error, message} - never exits.
defp try_register(config) do
commands = config.slash.commands()
with {:ok, app_id} <- resolve_app_id() do
Dexcord.Config.put_application_id(app_id)
register(app_id, commands, Map.get(config, :slash_guild_ids))
end
end
defp resolve_app_id do
case Dexcord.Api.get_current_application() do
{:ok, %{"id" => id}} when is_binary(id) ->
{:ok, id}
other ->
{:error,
"could not resolve the application id (GET /oauth2/applications/@me): " <>
inspect(other)}
end
end
# Guild mode: overwrite each guild, never touch globals.
defp register(app_id, commands, guild_ids)
when is_list(guild_ids) and guild_ids != [] do
result =
Enum.reduce_while(guild_ids, :ok, fn guild_id, _acc ->
case Dexcord.Api.bulk_overwrite_guild_commands(app_id, guild_id, commands) do
{:ok, _} ->
{:cont, :ok}
{:error, error} ->
{:halt,
{:error,
"guild command overwrite failed for guild #{inspect(guild_id)}: #{inspect(error)}"}}
end
end)
with :ok <- result do
Logger.info(
"Dexcord.Slash.Registrar: registered #{length(commands)} command(s) to " <>
"#{length(guild_ids)} guild(s). Global commands were NOT modified (guild/dev mode); " <>
"any previously-registered global commands remain - remove them manually if unwanted."
)
:ok
end
end
# Global mode with an empty command list: skip. Symmetric with guild mode - an
# empty global overwrite would wipe every production command every boot.
defp register(app_id, [] = _commands, _no_guild_ids) do
Logger.info(
"Dexcord.Slash.Registrar: commands/0 is empty in global mode; skipping the overwrite so " <>
"existing global commands are NOT wiped. To intentionally remove all global commands, " <>
"call Dexcord.Api.bulk_overwrite_global_commands(#{inspect(app_id)}, []) manually."
)
:ok
end
# Global mode: overwrite the application's global commands.
defp register(app_id, commands, _no_guild_ids) do
case Dexcord.Api.bulk_overwrite_global_commands(app_id, commands) do
{:ok, _} ->
Logger.info(
"Dexcord.Slash.Registrar: registered #{length(commands)} global command(s) " <>
"(propagation can take up to ~1h)."
)
:ok
{:error, error} ->
{:error, "global command overwrite failed: #{inspect(error)}"}
end
end
defp retry_delays do
Application.get_env(:dexcord, :registrar_retry_delays, @default_retry_delays)
end
end

View file

@ -16,11 +16,24 @@ defmodule Dexcord.Supervisor do
`max_restarts` trips in milliseconds and this supervisor terminates - surfacing `max_restarts` trips in milliseconds and this supervisor terminates - surfacing
the failure to the host application instead of hammering Discord. the failure to the host application instead of hammering Discord.
## Phase note ## Notes
`Dexcord.Cache` and `Dexcord.Slash.Registrar` are not started yet `Dexcord.Cache` owns the cache ETS tables and `Dexcord.Dispatcher` is their sole
(Cache -> Phase 4, Registrar -> Phase 5). `Dexcord.Api.Ratelimit` owns the writer; a Cache crash drops the named tables, which would make the still-running
Dispatcher's next `:ets.insert` raise. To contain that, the two are grouped into
a small nested supervisor with `strategy: :rest_for_one` (Cache first, then the
Dispatcher): a Cache restart re-creates the tables and then restarts the
Dispatcher after it, so the Dispatcher never writes into dropped tables, while a
Dispatcher-only crash restarts just the Dispatcher. This nested pair sits right
after `Dexcord.Session` (tables exist before anything writes) and before `Finch`;
it is isolated from the outer tree, so `Dexcord.Gateway`/`Dexcord.Session` crash
and resume semantics are completely unchanged. `Dexcord.Api.Ratelimit` owns the
REST rate-limit ETS and sits after Finch, before the Task supervisor. REST rate-limit ETS and sits after Finch, before the Task supervisor.
`Dexcord.Slash.Registrar` is added (as a `:transient` Task) between the
Dispatcher and the Gateway, but **only** when a `slash:` module is configured.
It needs Finch + the rate limiter (both earlier) and runs REST-only, so it
starts and registers commands before / in parallel with the gateway connecting.
""" """
use Supervisor use Supervisor
@ -35,16 +48,42 @@ defmodule Dexcord.Supervisor do
# Publish the validated config before any child that reads it starts. # Publish the validated config before any child that reads it starts.
Dexcord.Config.put(config) Dexcord.Config.put(config)
children = [ # Gateway is always last: it depends on everything above.
children =
[
Dexcord.Session, Dexcord.Session,
cache_and_dispatcher(),
{Finch, name: Dexcord.Finch}, {Finch, name: Dexcord.Finch},
Dexcord.Api.Ratelimit, Dexcord.Api.Ratelimit,
{Task.Supervisor, name: Dexcord.TaskSupervisor}, {Task.Supervisor, name: Dexcord.TaskSupervisor}
Dexcord.Dispatcher, ] ++
# Gateway is always last: it depends on everything above. registrar_child(config) ++
Dexcord.Gateway [Dexcord.Gateway]
]
Supervisor.init(children, strategy: :one_for_one, max_restarts: 5, max_seconds: 30) Supervisor.init(children, strategy: :one_for_one, max_restarts: 5, max_seconds: 30)
end end
# Cache + Dispatcher under their own :rest_for_one supervisor: a Cache crash
# (which drops the named ETS tables) restarts Cache and then the Dispatcher that
# writes them, so the Dispatcher can never write into dropped tables. Isolated
# from the outer tree, this leaves Gateway/Session restart semantics untouched.
defp cache_and_dispatcher do
%{
id: Dexcord.Cache.Supervisor,
start:
{Supervisor, :start_link,
[
[Dexcord.Cache, Dexcord.Dispatcher],
[strategy: :rest_for_one]
]},
type: :supervisor
}
end
# The slash-command Registrar Task, present only when a slash module is set.
defp registrar_child(%{slash: slash} = config) when is_atom(slash) and not is_nil(slash) do
[{Dexcord.Slash.Registrar, config}]
end
defp registrar_child(_config), do: []
end end

14
mix.exs
View file

@ -1,6 +1,8 @@
defmodule Dexcord.MixProject do defmodule Dexcord.MixProject do
use Mix.Project use Mix.Project
@source_url "https://github.com/luna/dexcord"
def project do def project do
[ [
app: :dexcord, app: :dexcord,
@ -8,7 +10,17 @@ defmodule Dexcord.MixProject do
elixir: "~> 1.18", elixir: "~> 1.18",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
deps: deps() deps: deps(),
name: "Dexcord",
source_url: @source_url,
docs: docs()
]
end
defp docs do
[
main: "readme",
extras: ["README.md"]
] ]
end end

View file

@ -16,6 +16,7 @@ defmodule Dexcord.ApiIntegrationTest do
@token "test.token.value" @token "test.token.value"
setup do setup do
Dexcord.EnvSandbox.sandbox_env()
Dexcord.Config.put(%{token: @token, handler: nil, intents: 0}) Dexcord.Config.put(%{token: @token, handler: nil, intents: 0})
start_supervised!({Finch, name: Dexcord.Finch}) start_supervised!({Finch, name: Dexcord.Finch})
@ -25,11 +26,6 @@ defmodule Dexcord.ApiIntegrationTest do
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url()) Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self()) FakeRest.subscribe(self())
on_exit(fn ->
Application.delete_env(:dexcord, :api_base_url)
Application.delete_env(:dexcord, :ratelimit_now_fn)
end)
:ok :ok
end end
@ -57,6 +53,9 @@ defmodule Dexcord.ApiIntegrationTest do
assert headers["authorization"] == "Bot #{@token}" assert headers["authorization"] == "Bot #{@token}"
assert headers["user-agent"] =~ "DiscordBot" assert headers["user-agent"] =~ "DiscordBot"
# Exactly one request went out.
refute_receive {:rest_hit, _}, 50
end end
test "204 responses decode to {:ok, nil}" do test "204 responses decode to {:ok, nil}" do
@ -137,9 +136,10 @@ defmodule Dexcord.ApiIntegrationTest do
{elapsed_us, result} = :timer.tc(fn -> Api.create_message(5, "hi") end) {elapsed_us, result} = :timer.tc(fn -> Api.create_message(5, "hi") end)
assert {:ok, %{"id" => "ok"}} = result assert {:ok, %{"id" => "ok"}} = result
# Two hits: the 429 and the successful retry. # Exactly two hits: the 429 and the successful retry - and no more.
assert_receive {:rest_hit, _} assert_receive {:rest_hit, _}
assert_receive {:rest_hit, _} assert_receive {:rest_hit, _}
refute_receive {:rest_hit, _}, 50
assert elapsed_us >= 200_000 assert elapsed_us >= 200_000
end end
@ -176,6 +176,65 @@ defmodule Dexcord.ApiIntegrationTest do
assert {:ok, %{"id" => "a"}} = Task.await(task, 5_000) assert {:ok, %{"id" => "a"}} = Task.await(task, 5_000)
end end
test "a wait that would exceed the request deadline returns an error tuple" do
Application.put_env(:dexcord, :api_deadline_ms, 100)
# Learn bucket DL with remaining 0 and a ~100s window; the next call would
# have to sleep far past the 100ms deadline.
FakeRest.stub(
:post,
"/channels/3/messages",
ok_json(~s({"id":"1"}), bucket: "DL", remaining: 0, reset_after: 100.0)
)
assert {:ok, _} = Api.create_message(3, "first")
assert_receive {:rest_hit, _}
assert {:error, %Error{status: nil, message: "rate limit deadline exceeded"}} =
Api.create_message(3, "second")
# The deadline tripped before any second request went on the wire.
refute_receive {:rest_hit, _}, 50
end
test "a non-JSON 2xx body is returned raw rather than dropped to nil" do
FakeRest.stub(
:get,
"/channels/77",
FakeRest.resp(200, headers: [{"content-type", "text/plain"}], body: "not json")
)
assert {:ok, {:raw, "not json"}} = Api.get_channel(77)
end
test "a non-JSON error body is preserved as a bounded snippet in the message" do
html = "<html><body>502 Bad Gateway</body></html>"
FakeRest.stub(
:get,
"/channels/78",
FakeRest.resp(502, headers: [{"content-type", "text/html"}], body: html)
)
assert {:error, %Error{status: 502, code: nil, message: msg}} = Api.get_channel(78)
assert msg =~ "Bad Gateway"
end
test "an encode that raises still lets the route recover (probe not wedged)" do
FakeRest.stub(
:post,
"/channels/999/messages",
ok_json(~s({"id":"ok"}), bucket: "RC", remaining: 5, reset_after: 1.0)
)
# A PID is not JSON-encodable: the first (probing) request raises during
# encode. The probe must still be released so the route isn't wedged.
assert catch_error(Api.create_message(999, %{"content" => self()}))
# A normal follow-up to the same route completes.
assert {:ok, %{"id" => "ok"}} = Api.create_message(999, "recovered")
end
# --- helpers ------------------------------------------------------------ # --- helpers ------------------------------------------------------------
defp global_lock_set?() do defp global_lock_set?() do

View file

@ -0,0 +1,84 @@
defmodule Dexcord.CacheCascadeTest do
@moduledoc """
Finding 4: a `Dexcord.Cache` crash must not cascade into the `Dexcord.Dispatcher`
(which would otherwise write into the dropped named ETS tables and raise), and
must never touch the gateway. Cache + Dispatcher live under a nested
`:rest_for_one` supervisor, so a Cache crash recreates the tables and restarts
the Dispatcher after it; the outer tree (gateway, session) is untouched.
"""
use ExUnit.Case, async: false
@token "fake.token.value"
defmodule Handler do
use Dexcord.Handler
end
# Grab a port, then release it so connecting to it is refused (gateway backs off).
defp closed_port do
{:ok, socket} = :gen_tcp.listen(0, [])
{:ok, port} = :inet.port(socket)
:ok = :gen_tcp.close(socket)
port
end
defp wait_until(_fun, 0), do: flunk("condition not met in time")
defp wait_until(fun, tries) do
if fun.() do
:ok
else
Process.sleep(10)
wait_until(fun, tries - 1)
end
end
setup do
Dexcord.EnvSandbox.sandbox_env()
Application.put_env(:dexcord, :backoff, {50, 500})
:ok
end
test "killing the Cache restarts Cache+Dispatcher, caches the next event, and leaves the gateway alive" do
port = closed_port()
start_supervised!(
{Dexcord,
token: @token, handler: Handler, intents: :default, gateway_url: "ws://127.0.0.1:#{port}"}
)
wait_until(fn -> is_pid(Process.whereis(Dexcord.Cache)) end, 200)
cache = Process.whereis(Dexcord.Cache)
dispatcher = Process.whereis(Dexcord.Dispatcher)
gateway = Process.whereis(Dexcord.Gateway)
assert is_pid(cache) and is_pid(dispatcher) and is_pid(gateway)
# Kill the Cache: it owns the named ETS tables, which vanish with it.
ref = Process.monitor(cache)
Process.exit(cache, :kill)
assert_receive {:DOWN, ^ref, :process, ^cache, :killed}, 1_000
# rest_for_one: Cache restarts, then the Dispatcher after it - both fresh pids.
wait_until(
fn ->
c = Process.whereis(Dexcord.Cache)
d = Process.whereis(Dexcord.Dispatcher)
is_pid(c) and c != cache and is_pid(d) and d != dispatcher
end,
200
)
# The next dispatch caches fine against the recreated tables - no crash.
guild = %{"id" => "g1", "name" => "Test Guild"}
Dexcord.Dispatcher.dispatch(:GUILD_CREATE, guild)
wait_until(fn -> match?({:ok, _}, Dexcord.Cache.guild("g1")) end, 200)
assert {:ok, %{"id" => "g1", "name" => "Test Guild"}} = Dexcord.Cache.guild("g1")
# The gateway was never restarted (still the same, still alive) - crash contained.
assert Process.whereis(Dexcord.Gateway) == gateway
assert Process.alive?(gateway)
assert Process.alive?(Process.whereis(Dexcord.Dispatcher))
end
end

View file

@ -0,0 +1,133 @@
defmodule Dexcord.CacheIntegrationTest do
@moduledoc """
Drives real dispatch events through the full supervision tree (Session, Cache,
Dispatcher, Gateway) against the scripted `Dexcord.FakeGateway`, asserting that
the cache reflects each event AND that the user handler observes the event only
*after* the cache already reflects it (the inline single-writer guarantee).
Also covers member chunking end to end: a GUILD_CREATE with
`request_guild_members: true` makes the client send op 8, the server replies with
a GUILD_MEMBERS_CHUNK, and the members land in the cache.
"""
use ExUnit.Case, async: false
alias Dexcord.Cache
alias Dexcord.FakeGateway
@token "cache.token.value"
setup do
Dexcord.EnvSandbox.sandbox_env()
Application.put_env(:dexcord, :backoff, {20, 200})
Application.put_env(:dexcord, :first_heartbeat_fraction, 1.0)
Application.put_env(:dexcord, :connect_timeout_ms, 3_000)
Application.put_env(:dexcord, :hello_timeout_ms, 3_000)
FakeGateway.CacheProbeHandler.subscribe(self())
:ok
end
@timeout 8_000
defp start_fake(opts) do
start_supervised!({FakeGateway, [test_pid: self()] ++ opts})
end
defp start_bot(fake, opts) do
start_supervised!(
{Dexcord,
[token: @token, handler: FakeGateway.CacheProbeHandler, gateway_url: FakeGateway.url(fake)] ++
opts}
)
end
defp await_ready do
assert_receive {:handler_saw, :READY, _}, @timeout
end
test "GUILD_CREATE populates the cache before the handler runs" do
fake = start_fake(hello_interval: 80)
start_bot(fake, intents: :all, cache_presences: true)
await_ready()
guild = %{
"id" => "g100",
"name" => "test",
"channels" => [%{"id" => "c100", "type" => 0}],
"roles" => [%{"id" => "r100", "name" => "everyone"}],
"members" => [%{"user" => %{"id" => "u100", "username" => "alice"}, "nick" => "a"}],
"voice_states" => [%{"user_id" => "u100", "channel_id" => "vc100"}],
"presences" => [%{"user" => %{"id" => "u100"}, "status" => "online"}]
}
FakeGateway.push_dispatch(fake, "GUILD_CREATE", guild, 10)
# The handler observed a cache that ALREADY had the guild (inline write ordering).
assert_receive {:handler_saw, :GUILD_CREATE, {:ok, %{"id" => "g100"}}}, @timeout
# And every fan-out table is populated.
assert {:ok, %{"guild_id" => "g100"}} = Cache.channel("c100")
assert {:ok, %{"name" => "everyone"}} = Cache.role("g100", "r100")
assert {:ok, %{"user_id" => "u100"}} = Cache.member("g100", "u100")
assert {:ok, %{"username" => "alice"}} = Cache.user("u100")
assert {:ok, %{"channel_id" => "vc100"}} = Cache.voice_state("g100", "u100")
assert {:ok, %{"status" => "online"}} = Cache.presence("g100", "u100")
end
test "MESSAGE_CREATE author is cached before the handler observes it" do
fake = start_fake(hello_interval: 80)
start_bot(fake, intents: :all)
await_ready()
msg = %{"id" => "m1", "author" => %{"id" => "au1", "username" => "bob"}, "content" => "hi"}
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", msg, 11)
assert_receive {:handler_saw, :MESSAGE_CREATE, {:ok, %{"username" => "bob"}}}, @timeout
end
test "chunking: GUILD_CREATE -> client sends op 8 -> CHUNK populates members" do
# request_guild_members needs the :guild_members intent bit; :all includes it.
fake = start_fake(hello_interval: 80)
start_bot(fake, intents: :all, request_guild_members: true, cache_presences: false)
await_ready()
# A real (non-stub) GUILD_CREATE with no inline members triggers the op 8 request.
FakeGateway.push_dispatch(fake, "GUILD_CREATE", %{"id" => "g200", "name" => "big"}, 20)
# The client asks for members (op 8) with the documented query/limit/presences.
assert_receive {:fake_gw, :frame, _c, %{"op" => 8, "d" => d}}, @timeout
assert d["guild_id"] == "g200"
assert d["query"] == ""
assert d["limit"] == 0
assert d["presences"] == false
# The server answers with a chunk; the members flow through dispatch into cache.
chunk = %{
"guild_id" => "g200",
"members" => [
%{"user" => %{"id" => "cu1", "username" => "x"}},
%{"user" => %{"id" => "cu2", "username" => "y"}}
]
}
FakeGateway.push_dispatch(fake, "GUILD_MEMBERS_CHUNK", chunk, 21)
assert_receive {:handler_saw, :GUILD_MEMBERS_CHUNK, members} when length(members) == 2,
@timeout
assert {:ok, _} = Cache.member("g200", "cu1")
assert {:ok, _} = Cache.member("g200", "cu2")
end
test "no op 8 is sent when request_guild_members is false" do
fake = start_fake(hello_interval: 80)
start_bot(fake, intents: :all, request_guild_members: false)
await_ready()
FakeGateway.push_dispatch(fake, "GUILD_CREATE", %{"id" => "g300", "name" => "q"}, 30)
assert_receive {:handler_saw, :GUILD_CREATE, {:ok, _}}, @timeout
refute_receive {:fake_gw, :frame, _c, %{"op" => 8}}, 300
end
end

329
test/dexcord/cache_test.exs Normal file
View file

@ -0,0 +1,329 @@
defmodule Dexcord.CacheTest do
@moduledoc """
Unit tests for `Dexcord.Cache.handle_dispatch/3` against real ETS. Each test
starts a fresh `Dexcord.Cache` (which owns and creates the tables), feeds it
string-keyed payload maps exactly as they arrive off the wire, and asserts table
contents through the public read API.
"""
use ExUnit.Case, async: false
alias Dexcord.Cache
@on %{cache_presences: true}
@off %{cache_presences: false}
setup do
start_supervised!(Dexcord.Cache)
:ok
end
defp user(id, name \\ "u"), do: %{"id" => id, "username" => "#{name}#{id}"}
defp member(uid, extra \\ %{}),
do: Map.merge(%{"user" => user(uid), "nick" => "nick#{uid}"}, extra)
# A full-ish guild payload as GUILD_CREATE delivers it.
defp guild_create(gid, opts \\ []) do
%{
"id" => gid,
"name" => "guild#{gid}",
"emojis" => [%{"id" => "e1", "name" => "smile"}],
"channels" => Keyword.get(opts, :channels, [%{"id" => "c#{gid}", "type" => 0}]),
"threads" => Keyword.get(opts, :threads, []),
"roles" => Keyword.get(opts, :roles, [%{"id" => "r#{gid}", "name" => "everyone"}]),
"members" => Keyword.get(opts, :members, [member("m#{gid}")]),
"voice_states" =>
Keyword.get(opts, :voice_states, [%{"user_id" => "m#{gid}", "channel_id" => "vc#{gid}"}]),
"presences" =>
Keyword.get(opts, :presences, [%{"user" => %{"id" => "m#{gid}"}, "status" => "online"}])
}
end
describe "READY" do
test "stores the bot user and unavailable guild stubs" do
data = %{
"user" => user("bot1", "self"),
"guilds" => [
%{"id" => "g1", "unavailable" => true},
%{"id" => "g2", "unavailable" => true}
]
}
Cache.handle_dispatch(:READY, data, @off)
assert {:ok, %{"id" => "bot1"}} = Cache.me()
assert {:ok, %{"unavailable" => true}} = Cache.guild("g1")
assert {:ok, %{"unavailable" => true}} = Cache.guild("g2")
end
end
describe "GUILD_CREATE" do
test "stores guild row with big arrays stripped and fans out children" do
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on)
{:ok, g} = Cache.guild("g1")
# Big arrays stripped...
for k <- ~w(channels threads roles members presences voice_states) do
refute Map.has_key?(g, k), "expected #{k} stripped from guild row"
end
# ...emojis stay inline.
assert [%{"name" => "smile"}] = g["emojis"]
# Fan-out: channel carries an injected guild_id.
assert {:ok, %{"guild_id" => "g1", "type" => 0}} = Cache.channel("cg1")
assert [%{"id" => "cg1"}] = Cache.channels("g1")
assert {:ok, %{"name" => "everyone"}} = Cache.role("g1", "rg1")
assert [%{"id" => "rg1"}] = Cache.roles("g1")
# Member: "user" moved to users table, member keeps "user_id".
{:ok, m} = Cache.member("g1", "mg1")
assert m["user_id"] == "mg1"
refute Map.has_key?(m, "user")
assert {:ok, %{"username" => _}} = Cache.user("mg1")
assert {:ok, %{"channel_id" => "vcg1"}} = Cache.voice_state("g1", "mg1")
assert {:ok, %{"status" => "online"}} = Cache.presence("g1", "mg1")
end
test "presences are skipped when cache_presences is false" do
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @off)
assert Cache.presence("g1", "mg1") == :error
assert Cache.presences("g1") == []
# But members/voice_states still populate.
assert {:ok, _} = Cache.member("g1", "mg1")
assert {:ok, _} = Cache.voice_state("g1", "mg1")
end
end
describe "GUILD_UPDATE / GUILD_EMOJIS_UPDATE" do
test "GUILD_UPDATE merges into the existing row without touching child tables" do
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on)
Cache.handle_dispatch(:GUILD_UPDATE, %{"id" => "g1", "name" => "renamed"}, @on)
{:ok, g} = Cache.guild("g1")
assert g["name"] == "renamed"
# Members untouched by a guild update.
assert {:ok, _} = Cache.member("g1", "mg1")
end
test "GUILD_EMOJIS_UPDATE replaces the inline emoji list" do
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on)
Cache.handle_dispatch(
:GUILD_EMOJIS_UPDATE,
%{"guild_id" => "g1", "emojis" => [%{"id" => "e9", "name" => "wave"}]},
@on
)
{:ok, g} = Cache.guild("g1")
assert [%{"id" => "e9", "name" => "wave"}] = g["emojis"]
end
end
describe "GUILD_DELETE cascade" do
test "purge wipes exactly that guild's rows across every per-guild table, and no others" do
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on)
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g2"), @on)
Cache.handle_dispatch(:GUILD_DELETE, %{"id" => "g1"}, @on)
# g1 gone everywhere.
assert Cache.guild("g1") == :error
assert Cache.members("g1") == []
assert Cache.roles("g1") == []
assert Cache.presences("g1") == []
assert Cache.voice_states("g1") == []
assert Cache.channels("g1") == []
# g2 fully intact.
assert {:ok, _} = Cache.guild("g2")
assert [_] = Cache.members("g2")
assert [_] = Cache.roles("g2")
assert [_] = Cache.presences("g2")
assert [_] = Cache.voice_states("g2")
assert [_] = Cache.channels("g2")
end
test "unavailable:true keeps a stub instead of purging" do
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on)
Cache.handle_dispatch(:GUILD_DELETE, %{"id" => "g1", "unavailable" => true}, @on)
assert {:ok, %{"unavailable" => true}} = Cache.guild("g1")
end
end
describe "channels and threads" do
test "CHANNEL_CREATE/UPDATE/DELETE and THREAD_* upsert and remove" do
Cache.handle_dispatch(
:CHANNEL_CREATE,
%{"id" => "c1", "guild_id" => "g1", "name" => "gen"},
@off
)
assert {:ok, %{"name" => "gen"}} = Cache.channel("c1")
Cache.handle_dispatch(
:CHANNEL_UPDATE,
%{"id" => "c1", "guild_id" => "g1", "name" => "renamed"},
@off
)
assert {:ok, %{"name" => "renamed"}} = Cache.channel("c1")
Cache.handle_dispatch(:THREAD_CREATE, %{"id" => "t1", "guild_id" => "g1"}, @off)
assert {:ok, _} = Cache.channel("t1")
Cache.handle_dispatch(:THREAD_DELETE, %{"id" => "t1"}, @off)
assert Cache.channel("t1") == :error
Cache.handle_dispatch(:CHANNEL_DELETE, %{"id" => "c1"}, @off)
assert Cache.channel("c1") == :error
end
end
describe "members" do
test "ADD/UPDATE move user out and merge; REMOVE deletes the member" do
Cache.handle_dispatch(:GUILD_MEMBER_ADD, member("u1", %{"guild_id" => "g1"}), @off)
{:ok, m} = Cache.member("g1", "u1")
assert m["user_id"] == "u1"
assert m["nick"] == "nicku1"
assert {:ok, _} = Cache.user("u1")
# Update merges (keeps prior fields, changes nick).
Cache.handle_dispatch(
:GUILD_MEMBER_UPDATE,
%{"guild_id" => "g1", "user" => user("u1"), "nick" => "new"},
@off
)
{:ok, m2} = Cache.member("g1", "u1")
assert m2["nick"] == "new"
Cache.handle_dispatch(
:GUILD_MEMBER_REMOVE,
%{"guild_id" => "g1", "user" => user("u1")},
@off
)
assert Cache.member("g1", "u1") == :error
# User survives removal from one guild.
assert {:ok, _} = Cache.user("u1")
end
test "GUILD_MEMBERS_CHUNK bulk upserts members + users, and presences when present" do
data = %{
"guild_id" => "g1",
"members" => [member("u1"), member("u2")],
"presences" => [%{"user" => %{"id" => "u1"}, "status" => "idle"}]
}
Cache.handle_dispatch(:GUILD_MEMBERS_CHUNK, data, @on)
assert length(Cache.members("g1")) == 2
assert {:ok, _} = Cache.user("u1")
assert {:ok, _} = Cache.user("u2")
assert {:ok, %{"status" => "idle"}} = Cache.presence("g1", "u1")
end
end
describe "roles" do
test "ROLE_CREATE/UPDATE/DELETE" do
Cache.handle_dispatch(
:GUILD_ROLE_CREATE,
%{"guild_id" => "g1", "role" => %{"id" => "r1", "name" => "mod"}},
@off
)
assert {:ok, %{"name" => "mod"}} = Cache.role("g1", "r1")
Cache.handle_dispatch(
:GUILD_ROLE_UPDATE,
%{"guild_id" => "g1", "role" => %{"id" => "r1", "name" => "admin"}},
@off
)
assert {:ok, %{"name" => "admin"}} = Cache.role("g1", "r1")
Cache.handle_dispatch(:GUILD_ROLE_DELETE, %{"guild_id" => "g1", "role_id" => "r1"}, @off)
assert Cache.role("g1", "r1") == :error
end
end
describe "presences toggle" do
test "PRESENCE_UPDATE writes only when cache_presences is true" do
p = %{"guild_id" => "g1", "user" => %{"id" => "u1"}, "status" => "dnd"}
Cache.handle_dispatch(:PRESENCE_UPDATE, p, @off)
assert Cache.presence("g1", "u1") == :error
Cache.handle_dispatch(:PRESENCE_UPDATE, p, @on)
assert {:ok, %{"status" => "dnd"}} = Cache.presence("g1", "u1")
end
end
describe "voice states" do
test "upsert on channel_id, delete when channel_id is nil" do
Cache.handle_dispatch(
:VOICE_STATE_UPDATE,
%{"guild_id" => "g1", "user_id" => "u1", "channel_id" => "vc1"},
@off
)
assert {:ok, %{"channel_id" => "vc1"}} = Cache.voice_state("g1", "u1")
Cache.handle_dispatch(
:VOICE_STATE_UPDATE,
%{"guild_id" => "g1", "user_id" => "u1", "channel_id" => nil},
@off
)
assert Cache.voice_state("g1", "u1") == :error
end
end
describe "USER_UPDATE and MESSAGE_CREATE" do
test "USER_UPDATE merges into me" do
Cache.handle_dispatch(:READY, %{"user" => user("bot1", "self"), "guilds" => []}, @off)
Cache.handle_dispatch(:USER_UPDATE, %{"id" => "bot1", "username" => "renamed"}, @off)
assert {:ok, %{"username" => "renamed"}} = Cache.me()
end
test "MESSAGE_CREATE opportunistically upserts author and member fragment" do
data = %{
"guild_id" => "g1",
"author" => user("a1"),
"member" => %{"nick" => "frag", "roles" => ["r1"]}
}
Cache.handle_dispatch(:MESSAGE_CREATE, data, @off)
assert {:ok, _} = Cache.user("a1")
{:ok, m} = Cache.member("g1", "a1")
assert m["user_id"] == "a1"
assert m["nick"] == "frag"
end
test "MESSAGE_CREATE ignores webhook authors" do
data = %{"webhook_id" => "wh1", "author" => %{"id" => "wh1", "username" => "hook"}}
Cache.handle_dispatch(:MESSAGE_CREATE, data, @off)
assert Cache.user("wh1") == :error
end
end
describe "read API shapes" do
test "bang variants return the value or raise" do
Cache.handle_dispatch(:GUILD_CREATE, guild_create("g1"), @on)
assert %{"id" => "g1"} = Cache.guild!("g1")
assert_raise RuntimeError, fn -> Cache.guild!("nope") end
assert_raise RuntimeError, fn -> Cache.member!("g1", "nope") end
end
test "listings return plain lists (empty, never :error)" do
assert Cache.guilds() == []
assert Cache.channels("g1") == []
assert Cache.members("g1") == []
end
end
end

View file

@ -0,0 +1,78 @@
defmodule Dexcord.ConfigValidationTest do
use ExUnit.Case, async: true
@base [token: "t", handler: MyBot.Handler]
defp validate(extra), do: Dexcord.validate(@base ++ extra)
describe "intents" do
test "a tuple raises a friendly ArgumentError, not a FunctionClauseError" do
assert_raise ArgumentError, ~r/:intents must be :all, :default/, fn ->
validate(intents: {:guilds})
end
end
test "a negative integer raises a friendly ArgumentError" do
assert_raise ArgumentError, ~r/:intents must be/, fn -> validate(intents: -1) end
end
test "an unknown intent atom in a list keeps the specific message" do
assert_raise ArgumentError, ~r/unknown intent/, fn ->
validate(intents: [:guilds, :not_a_real_intent])
end
end
test "valid specs resolve" do
assert %{intents: bits} = validate(intents: :all)
assert is_integer(bits) and bits > 0
assert %{intents: 0} = validate(intents: 0)
end
end
describe "gateway_url" do
test "a non-ws/http url raises" do
assert_raise ArgumentError, ~r/:gateway_url must start with/, fn ->
validate(gateway_url: "gopher://x")
end
end
test "a non-binary raises instead of being silently ignored" do
assert_raise ArgumentError, ~r/:gateway_url must be a URL string/, fn ->
validate(gateway_url: 123)
end
end
test "accepts ws:// wss:// http:// https:// and nil" do
for url <- ["ws://x", "wss://x", "http://x", "https://x"] do
assert %{gateway_url: ^url} = validate(gateway_url: url)
end
assert %{gateway_url: nil} = validate([])
end
end
describe "slash + slash_guild_ids" do
test "slash must be a module" do
assert_raise ArgumentError, ~r/:slash must be a module/, fn -> validate(slash: "MyBot") end
assert_raise ArgumentError, ~r/:slash must be a module/, fn -> validate(slash: 123) end
end
test "slash accepts a module or nil" do
assert %{slash: MyBot.Slash} = validate(slash: MyBot.Slash)
assert %{slash: nil} = validate([])
end
test "slash_guild_ids must be a list of binaries/integers" do
assert_raise ArgumentError, ~r/:slash_guild_ids/, fn -> validate(slash_guild_ids: "123") end
assert_raise ArgumentError, ~r/:slash_guild_ids/, fn ->
validate(slash_guild_ids: [%{}])
end
end
test "slash_guild_ids accepts strings, integers, and nil" do
assert %{slash_guild_ids: ["1", 2]} = validate(slash_guild_ids: ["1", 2])
assert %{slash_guild_ids: nil} = validate([])
end
end
end

View file

@ -0,0 +1,138 @@
defmodule Dexcord.ErgTest do
@moduledoc false
# Test sink: relay messages from the handler / router / slash module back to the
# controlling test process (whose pid lives in persistent_term).
@key {__MODULE__, :sink}
def set_sink(pid), do: :persistent_term.put(@key, pid)
def notify(msg) do
case :persistent_term.get(@key, nil) do
nil -> :ok
pid -> send(pid, msg)
end
end
end
defmodule Dexcord.ErgTest.Router do
@moduledoc false
use Dexcord.Prefix.Router
def handle_command(cmd, args, msg), do: Dexcord.ErgTest.notify({:command, cmd, args, msg})
end
defmodule Dexcord.ErgTest.Slash do
@moduledoc false
use Dexcord.Slash
def commands, do: [%{name: "ping", description: "Pong!"}]
def handle_interaction(name, itx), do: Dexcord.ErgTest.notify({:slash, name, itx})
end
defmodule Dexcord.ErgTest.Handler do
@moduledoc false
# Not `use Dexcord.Handler`: we want an explicit catch-all 2-tuple clause that
# relays every other event, which would collide with the injected catch-all.
@behaviour Dexcord.Handler
def handle_event({:MESSAGE_CREATE, msg}),
do: Dexcord.Prefix.dispatch(msg, prefix: "!", to: Dexcord.ErgTest.Router)
def handle_event({name, data}), do: Dexcord.ErgTest.notify({:raw, name, data})
end
defmodule Dexcord.ErgonomicsIntegrationTest do
@moduledoc """
End-to-end ergonomics: prefix commands, slash auto-routing, and `update_presence`
driven through the real gateway (`Dexcord.FakeGateway`) with slash registration
served by `Dexcord.FakeRest`.
"""
use ExUnit.Case, async: false
alias Dexcord.FakeGateway
alias Dexcord.FakeRest
@token "test.token.value"
@timeout 8_000
setup do
Dexcord.EnvSandbox.sandbox_env()
Application.put_env(:dexcord, :backoff, {20, 200})
Application.put_env(:dexcord, :first_heartbeat_fraction, 1.0)
Application.put_env(:dexcord, :identify_gap_ms, 50)
Application.put_env(:dexcord, :connect_timeout_ms, 2_500)
Application.put_env(:dexcord, :hello_timeout_ms, 2_500)
Application.put_env(:dexcord, :tcp_connect_timeout_ms, 2_500)
Dexcord.ErgTest.set_sink(self())
# Registrar (started because slash: is set) needs a REST server to answer.
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.stub(
:get,
"/oauth2/applications/@me",
FakeRest.resp(200, body: ~s({"id":"app-erg"}))
)
FakeRest.stub(:put, "/applications/app-erg/commands", FakeRest.resp(200, body: "[]"))
fake = start_supervised!({FakeGateway, test_pid: self(), hello_interval: 80})
:ok = start_bot(fake)
# Synchronise on READY so the gateway is :connected before we push anything.
assert_receive {:raw, :READY, _}, @timeout
{:ok, fake: fake}
end
defp start_bot(fake) do
start_supervised!(
{Dexcord,
token: @token,
handler: Dexcord.ErgTest.Handler,
intents: :all,
slash: Dexcord.ErgTest.Slash,
gateway_url: FakeGateway.url(fake)}
)
:ok
end
test "prefix command end-to-end: MESSAGE_CREATE reaches the router; bot authors are skipped",
%{fake: fake} do
user_msg = %{"content" => "!ping a b", "author" => %{"id" => "u1", "bot" => false}}
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", user_msg, 10)
assert_receive {:command, "ping", ["a", "b"], ^user_msg}, @timeout
bot_msg = %{"content" => "!ping", "author" => %{"id" => "b1", "bot" => true}}
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", bot_msg, 11)
refute_receive {:command, _, _, _}, 300
end
test "INTERACTION_CREATE (type 2) auto-routes to the slash module AND reaches the raw handler",
%{fake: fake} do
itx = %{"id" => "i2", "type" => 2, "token" => "tok", "data" => %{"name" => "ping"}}
FakeGateway.push_dispatch(fake, "INTERACTION_CREATE", itx, 20)
assert_receive {:slash, "ping", ^itx}, @timeout
assert_receive {:raw, :INTERACTION_CREATE, ^itx}, @timeout
end
test "an autocomplete interaction (type 4) reaches the raw handler but is not auto-routed",
%{fake: fake} do
itx = %{"id" => "i4", "type" => 4, "data" => %{"name" => "ping"}}
FakeGateway.push_dispatch(fake, "INTERACTION_CREATE", itx, 21)
assert_receive {:raw, :INTERACTION_CREATE, ^itx}, @timeout
refute_receive {:slash, _, _}, 300
end
test "update_presence/1 flows through the send budget to the gateway", %{fake: _fake} do
presence = %{"status" => "dnd", "activities" => [%{"name" => "with fire", "type" => 0}]}
assert :ok = Dexcord.update_presence(presence)
assert_receive {:fake_gw, :frame, _conn, %{"op" => 3} = frame}, @timeout
assert frame["d"] == presence
end
end

View file

@ -5,14 +5,7 @@ defmodule Dexcord.GatewayBackoffTest do
alias Dexcord.Gateway alias Dexcord.Gateway
setup do setup do
prev = Application.get_env(:dexcord, :backoff) Dexcord.EnvSandbox.sandbox_env()
on_exit(fn ->
if prev == nil,
do: Application.delete_env(:dexcord, :backoff),
else: Application.put_env(:dexcord, :backoff, prev)
end)
:ok :ok
end end

View file

@ -12,24 +12,13 @@ defmodule Dexcord.GatewayFatalTest do
alias Dexcord.FakeGateway alias Dexcord.FakeGateway
setup do setup do
prev = Dexcord.EnvSandbox.sandbox_env()
for key <- [:backoff, :connect_timeout_ms, :hello_timeout_ms, :tcp_connect_timeout_ms] do
{key, Application.get_env(:dexcord, key)}
end
Application.put_env(:dexcord, :backoff, {10, 50}) Application.put_env(:dexcord, :backoff, {10, 50})
Application.put_env(:dexcord, :connect_timeout_ms, 2_000) Application.put_env(:dexcord, :connect_timeout_ms, 2_000)
Application.put_env(:dexcord, :hello_timeout_ms, 2_000) Application.put_env(:dexcord, :hello_timeout_ms, 2_000)
Application.put_env(:dexcord, :tcp_connect_timeout_ms, 2_000) Application.put_env(:dexcord, :tcp_connect_timeout_ms, 2_000)
on_exit(fn ->
for {key, val} <- prev do
if val == nil,
do: Application.delete_env(:dexcord, key),
else: Application.put_env(:dexcord, key, val)
end
end)
FakeGateway.TestHandler.subscribe(self()) FakeGateway.TestHandler.subscribe(self())
:ok :ok
end end
@ -53,7 +42,24 @@ defmodule Dexcord.GatewayFatalTest do
Process.flag(:trap_exit, true) Process.flag(:trap_exit, true)
{:ok, sup} = Dexcord.Supervisor.start_link(config) {:ok, sup} = Dexcord.Supervisor.start_link(config)
on_exit(fn -> if Process.alive?(sup), do: Process.exit(sup, :kill) end) # AWAIT full teardown: this supervisor owns the globally-named singletons
# (Session/Cache/Ratelimit/Finch/Dispatcher/Gateway) and their `:named_table`
# ETS. If it is still terminating when the next test's tree starts, that test
# hits already-registered names / ETS-name clashes. Monitor and block until the
# supervisor (and therefore its children + tables) is truly gone.
on_exit(fn ->
if Process.alive?(sup) do
ref = Process.monitor(sup)
Process.exit(sup, :kill)
receive do
{:DOWN, ^ref, :process, ^sup, _reason} -> :ok
after
5_000 -> :ok
end
end
end)
{fake, sup} {fake, sup}
end end

View file

@ -10,26 +10,16 @@ defmodule Dexcord.GatewayIntegrationTest do
use ExUnit.Case, async: false use ExUnit.Case, async: false
alias Dexcord.FakeGateway alias Dexcord.FakeGateway
alias Dexcord.Gateway
alias Dexcord.Intents alias Dexcord.Intents
alias Dexcord.Session alias Dexcord.Session
@token "test.token.value" @token "test.token.value"
setup do setup do
# Deterministic, fast timing knobs. Restored after each test. # Deterministic, fast timing knobs. The env sandbox restores everything (whatever
prev = # keys these tests set) wholesale after each test.
for key <- [ Dexcord.EnvSandbox.sandbox_env()
:backoff,
:invalid_session_delay_ms,
:identify_gap_ms,
:first_heartbeat_fraction,
:max_resume_attempts,
:connect_timeout_ms,
:hello_timeout_ms,
:tcp_connect_timeout_ms
] do
{key, Application.get_env(:dexcord, key)}
end
Application.put_env(:dexcord, :backoff, {20, 200}) Application.put_env(:dexcord, :backoff, {20, 200})
Application.put_env(:dexcord, :first_heartbeat_fraction, 1.0) Application.put_env(:dexcord, :first_heartbeat_fraction, 1.0)
@ -44,14 +34,6 @@ defmodule Dexcord.GatewayIntegrationTest do
Application.put_env(:dexcord, :hello_timeout_ms, 2_500) Application.put_env(:dexcord, :hello_timeout_ms, 2_500)
Application.put_env(:dexcord, :tcp_connect_timeout_ms, 2_500) Application.put_env(:dexcord, :tcp_connect_timeout_ms, 2_500)
on_exit(fn ->
for {key, val} <- prev do
if val == nil,
do: Application.delete_env(:dexcord, key),
else: Application.put_env(:dexcord, key, val)
end
end)
FakeGateway.TestHandler.subscribe(self()) FakeGateway.TestHandler.subscribe(self())
:ok :ok
end end
@ -213,6 +195,107 @@ defmodule Dexcord.GatewayIntegrationTest do
assert_receive {:fake_gw, :frame, _c, %{"op" => 1}}, 500 assert_receive {:fake_gw, :frame, _c, %{"op" => 1}}, 500
end end
# --- REVIEW FIX 1: op-0 frame with t:null must not crash the statem ------
# Verifies review finding #1. An op-0 dispatch whose `t` is JSON null used to
# crash the gen_statem: bump_and_dispatch called EventNames.to_atom(nil), whose
# only clause is is_binary-guarded, raising FunctionClauseError and killing the
# process. The op-0 handling is now tolerant of a non-binary `t` (logs debug,
# still bumps seq, skips dispatch), so the statem survives and stays connected.
test "FIX: an op-0 frame with t:null is tolerated - statem survives and stays connected" do
fake = start_fake(hello_interval: 80, session_id: "sess-tnull")
start_bot(fake)
assert_frame(2)
assert_receive {:handler_event, {:READY, _}}, @event_timeout
baseline = FakeGateway.conn_count(fake)
# Malformed dispatch: op 0, "t": null, but carrying a sequence number.
FakeGateway.push_dispatch(fake, nil, %{"garbage" => true}, 5)
# Seq is still bumped from the (garbage) frame even though nothing is dispatched.
wait_until(fn -> Session.last_seq() == 5 end)
assert Session.last_seq() == 5
# The statem survived: a following real dispatch still reaches the user handler.
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "alive"}, 6)
assert_receive {:handler_event, {:MESSAGE_CREATE, %{"content" => "alive"}}}, @event_timeout
# And it never crashed/reconnected: heartbeats keep flowing on the same connection.
assert_receive {:fake_gw, :frame, _c, %{"op" => 1}}, @event_timeout
assert FakeGateway.conn_count(fake) == baseline, "statem crashed and reconnected"
end
# --- REVIEW FIX 2b: abandoned-session frames must not repopulate last_seq -
# Verifies review finding #2. After op 9 d:false clears the session (last_seq -> nil)
# the client waits out the invalid-session delay before IDENTIFYing on the same
# socket. A dispatch trickling in during that wait belongs to the ABANDONED session;
# it must be delivered to the handler but must NOT bump last_seq, or the foreign
# sequence would cross into the fresh session about to be established.
test "FIX: a dispatch during the op 9 d:false reidentify wait does not repopulate last_seq" do
# Large invalid-session wait so the reidentify window comfortably spans the
# trickle+assert (no READY can arrive to reseed last_seq before we check).
Application.put_env(:dexcord, :invalid_session_delay_ms, {800, 1_200})
Application.put_env(:dexcord, :identify_gap_ms, 10)
disable_handshake_retries()
fake =
start_fake(hello_interval: 100, session_id: "sess-seq", on_resume: {:invalid, false})
start_bot(fake)
assert_frame(2)
assert_receive {:handler_event, {:READY, _}}, @event_timeout
# Advance the live session's seq.
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "x"}, 42)
assert_receive {:handler_event, {:MESSAGE_CREATE, %{"content" => "x"}}}, @event_timeout
wait_until(fn -> Session.last_seq() == 42 end)
# Force a resume; the server rejects it with op 9 d:false, clearing the session.
FakeGateway.push_op7(fake)
{_rconn, 6, _} = assert_reconnect_frame()
wait_until(fn -> Session.last_seq() == nil end)
# Trickle a dispatch from the abandoned session during the reidentify wait.
FakeGateway.push_dispatch(fake, "MESSAGE_CREATE", %{"content" => "noise"}, 9_999)
assert_receive {:handler_event, {:MESSAGE_CREATE, %{"content" => "noise"}}}, @event_timeout
# It was delivered, but its foreign seq must not have repopulated last_seq.
assert Session.last_seq() == nil, "abandoned-session frame repopulated last_seq"
end
# --- REVIEW FIX 4: op-8/op-3 casts outside :connected are queued, not dropped -
# Verifies review finding #4. A request_guild_members (op 8) issued while the client
# is parked in :resuming (e.g. reacting to GUILD_CREATEs mid-replay) used to be
# dropped by the cast catch-all. It is now enqueued and drains once :connected is
# re-entered, so the member-chunk request is not lost.
test "FIX: request_guild_members cast during :resuming is queued and sent after RESUMED" do
# :manual resume keeps the client parked in :resuming until we push RESUMED.
fake = start_fake(hello_interval: 100, session_id: "sess-q8", on_resume: :manual)
start_bot(fake, intents: :all)
assert_frame(2)
assert_receive {:handler_event, {:READY, _}}, @event_timeout
# Force a resume; the client sends op 6 and (manual) stays in :resuming.
FakeGateway.push_op7(fake)
{_rconn, 6, _} = assert_reconnect_frame()
# Cast an op-8 while parked in :resuming: it must be queued, not dropped.
Gateway.request_guild_members("123", limit: 0)
# Nothing goes out while still resuming...
refute_receive {:fake_gw, :frame, _c, %{"op" => 8}}, 200
# ...then RESUMED promotes us to :connected and the queue drains.
FakeGateway.push_resumed(fake)
assert_receive {:fake_gw, :frame, _c, %{"op" => 8, "d" => %{"guild_id" => "123"}}},
@event_timeout
end
# --- zombie detection --------------------------------------------------- # --- zombie detection ---------------------------------------------------
test "zombie: dropping ACKs closes ~2x interval later and RESUMEs with correct session/seq" do test "zombie: dropping ACKs closes ~2x interval later and RESUMEs with correct session/seq" do

View file

@ -0,0 +1,152 @@
defmodule Dexcord.GatewaySendBudgetTest do
@moduledoc """
Exercises the gateway send-budget token bucket with shrunk knobs: op-3 presence
updates and op-8 member requests consume tokens and queue when the budget is
exhausted, draining as tokens refill, while heartbeats bypass the bucket
entirely. The queue is bounded and over-bound frames are dropped.
"""
use ExUnit.Case, async: false
alias Dexcord.FakeGateway
alias Dexcord.Gateway
@token "budget.token.value"
@timeout 8_000
setup do
Dexcord.EnvSandbox.sandbox_env()
Application.put_env(:dexcord, :backoff, {20, 200})
Application.put_env(:dexcord, :first_heartbeat_fraction, 1.0)
Application.put_env(:dexcord, :connect_timeout_ms, 3_000)
Application.put_env(:dexcord, :hello_timeout_ms, 3_000)
FakeGateway.TestHandler.subscribe(self())
:ok
end
defp start_bot(fake) do
start_supervised!(
{Dexcord,
token: @token,
handler: FakeGateway.TestHandler,
intents: :all,
gateway_url: FakeGateway.url(fake)}
)
end
# Drain op-3 frames from the mailbox, returning the count once quiet for `quiet`
# ms. Leaves non-op-3 frames (heartbeats) in the mailbox.
defp count_op3(quiet), do: count_op3(0, quiet)
defp count_op3(n, quiet) do
receive do
{:fake_gw, :frame, _c, %{"op" => 3}} -> count_op3(n + 1, quiet)
after
quiet -> n
end
end
test "presence updates beyond the budget queue and drain as tokens refill" do
# 2 tokens per 250ms window -> 1 token every 125ms. Generous queue so nothing
# drops: all five sends should eventually be delivered.
Application.put_env(:dexcord, :send_budget, {2, 250})
Application.put_env(:dexcord, :send_queue_max, 128)
fake = start_supervised!({FakeGateway, test_pid: self(), hello_interval: 60})
start_bot(fake)
assert_receive {:handler_event, {:READY, _}}, @timeout
for i <- 1..5, do: Gateway.update_presence(%{"status" => "s#{i}"})
# First two go out immediately (tokens), the rest are queued and drained on
# refill ticks. All five arrive.
assert count_op3(1_200) == 5
# Heartbeats bypass the budget entirely and keep flowing even while op-3 was
# queued/throttled.
assert_receive {:fake_gw, :frame, _c, %{"op" => 1}}, @timeout
end
# Drain op-3 status strings from the mailbox in arrival order until quiet.
defp op3_statuses(quiet), do: op3_statuses([], quiet) |> Enum.reverse()
defp op3_statuses(acc, quiet) do
receive do
{:fake_gw, :frame, _c, %{"op" => 3, "d" => %{"status" => s}}} ->
op3_statuses([s | acc], quiet)
after
quiet -> acc
end
end
# Verifies review finding #3: queue-first FIFO. When frames are already queued and a
# token later becomes available, a newly-cast frame must fall in BEHIND the queued
# ones, not jump ahead of them (Discord is last-write-wins, so reordering an op-3
# would let a stale presence overwrite a newer one).
#
# Determinism trick: arm the refill tick with a LONG window (so it won't fire during
# the test), then shrink the window via app-env so `refill/1` - which reads config
# live - credits a full token quickly. That produces the exact state the bug needs
# (queue non-empty AND a token available) with no pending drain tick to race.
test "queue-first FIFO: a frame cast after a refill does not jump earlier queued frames" do
# One token; huge window means the enqueue-armed refill tick lands ~5s out and
# never fires during the test.
Application.put_env(:dexcord, :send_budget, {1, 5_000})
Application.put_env(:dexcord, :send_queue_max, 128)
fake = start_supervised!({FakeGateway, test_pid: self(), hello_interval: 60})
start_bot(fake)
assert_receive {:handler_event, {:READY, _}}, @timeout
# Spend the single initial token on a warmup send (goes out immediately).
Gateway.update_presence(%{"status" => "warmup"})
assert_receive {:fake_gw, :frame, _c, %{"op" => 3, "d" => %{"status" => "warmup"}}}, @timeout
# Budget now empty; A and B queue (refill tick armed ~5s out with the huge window).
Gateway.update_presence(%{"status" => "A"})
Gateway.update_presence(%{"status" => "B"})
# Synchronize: casts are async, so block until the statem has actually PROCESSED
# both (a gen_statem sync call is FIFO-ordered behind the casts). This pins
# send_last_refill at B's processing time, so the sleep below genuinely elapses
# between B and C being handled - without it the refill window is a race and the
# test is nondeterministic. Also confirms both frames really queued.
{_state, data} = :sys.get_state(Dexcord.Gateway)
assert :queue.len(data.send_queue) == 2, "A and B should be queued (budget empty)"
# Shrink the window so a token accrues fast; the already-armed ~5s tick is stale.
Application.put_env(:dexcord, :send_budget, {1, 100})
# Past one shrunk window: refill/1 will now credit >= 1 token on C's cast.
Process.sleep(150)
# Cast C. A token is available AND the queue is non-empty - the exact case the
# bug mishandled. With the fix C queues behind A,B and the drain emits A,B,C; with
# the bug C jumps the queue (sent immediately) and A,B are left stranded.
Gateway.update_presence(%{"status" => "C"})
assert op3_statuses(1_000) == ["A", "B", "C"]
end
test "queue bound drops excess frames while heartbeats still bypass" do
# 1 token per 400ms, queue holds at most 1: of five rapid sends, 1 goes out on
# the initial token, 1 is queued, the other 3 are dropped. The queued one drains
# after ~400ms -> exactly 2 op-3 frames ever delivered.
Application.put_env(:dexcord, :send_budget, {1, 400})
Application.put_env(:dexcord, :send_queue_max, 1)
fake = start_supervised!({FakeGateway, test_pid: self(), hello_interval: 60})
start_bot(fake)
assert_receive {:handler_event, {:READY, _}}, @timeout
for i <- 1..5, do: Gateway.update_presence(%{"status" => "s#{i}"})
assert count_op3(1_000) == 2
# And heartbeats never stopped despite the op-3 budget being pinned at empty.
assert_receive {:fake_gw, :frame, _c, %{"op" => 1}}, @timeout
end
end

View file

@ -0,0 +1,106 @@
defmodule Dexcord.PrefixTest do
use ExUnit.Case, async: true
alias Dexcord.Prefix
doctest Dexcord.Prefix
describe "parse/2" do
test "matches a bare command with no args" do
assert Prefix.parse("!ping", "!") == {:ok, "ping", "", []}
end
test "splits multi-word args" do
assert Prefix.parse("!echo hello world", "!") ==
{:ok, "echo", "hello world", ["hello", "world"]}
end
test "collapses runs of whitespace in the args list but preserves them in arg_string" do
assert Prefix.parse("!echo a b ", "!") ==
{:ok, "echo", "a b", ["a", "b"]}
end
test "tolerates whitespace between the prefix and the command" do
assert Prefix.parse("! ping arg", "!") ==
{:ok, "ping", "arg", ["arg"]}
end
test "returns :nomatch when the prefix is absent" do
assert Prefix.parse("ping", "!") == :nomatch
assert Prefix.parse("?ping", "!") == :nomatch
end
test "returns :nomatch for a prefix with nothing after it" do
assert Prefix.parse("!", "!") == :nomatch
assert Prefix.parse("! ", "!") == :nomatch
end
test "supports multi-character prefixes" do
assert Prefix.parse(">>roll 2d6", ">>") == {:ok, "roll", "2d6", ["2d6"]}
end
test "handles unicode commands and args" do
assert Prefix.parse("!café ☕ latte", "!") ==
{:ok, "café", "☕ latte", ["", "latte"]}
end
test "handles a unicode prefix" do
assert Prefix.parse("✨spell fireball", "") ==
{:ok, "spell", "fireball", ["fireball"]}
end
test "returns :nomatch for non-binary inputs or an empty prefix" do
assert Prefix.parse(nil, "!") == :nomatch
assert Prefix.parse("!ping", "") == :nomatch
assert Prefix.parse(123, "!") == :nomatch
end
end
describe "dispatch/2 + Router" do
defmodule Router do
use Dexcord.Prefix.Router
def handle_command("ping", args, msg) do
send(self(), {:routed, "ping", args, msg})
:handled
end
end
setup do
# dispatch/2's bot-author self-check reads Dexcord.Cache.me/0, so the cache
# ETS tables must exist.
start_supervised!(Dexcord.Cache)
:ok
end
test "skips the bot's own messages via the cached self id, even without a bot flag" do
Dexcord.Cache.handle_dispatch(:READY, %{"user" => %{"id" => "me-id"}}, %{})
msg = %{"content" => "!ping", "author" => %{"id" => "me-id"}}
assert Prefix.dispatch(msg, prefix: "!", to: Router) == :ignore
refute_received {:routed, _, _, _}
end
test "routes a matching command to the router" do
msg = %{"content" => "!ping a b", "author" => %{"id" => "7", "bot" => false}}
assert Prefix.dispatch(msg, prefix: "!", to: Router) == :handled
assert_received {:routed, "ping", ["a", "b"], ^msg}
end
test "the injected catch-all returns :ignore for unknown commands" do
msg = %{"content" => "!unknown", "author" => %{"id" => "7"}}
assert Prefix.dispatch(msg, prefix: "!", to: Router) == :ignore
end
test "returns :ignore on a non-match without touching the router" do
msg = %{"content" => "no prefix", "author" => %{"id" => "7"}}
assert Prefix.dispatch(msg, prefix: "!", to: Router) == :ignore
refute_received {:routed, _, _, _}
end
test "skips messages whose author is a bot" do
msg = %{"content" => "!ping", "author" => %{"id" => "7", "bot" => true}}
assert Prefix.dispatch(msg, prefix: "!", to: Router) == :ignore
refute_received {:routed, _, _, _}
end
end
end

View file

@ -43,16 +43,50 @@ defmodule Dexcord.Api.RatelimitTest do
"PUT /channels/1/messages/:id/reactions/:id/@me" "PUT /channels/1/messages/:id/reactions/:id/@me"
end end
test "webhook id + token are both major (literal)" do test "webhook id stays literal; the secret token becomes a stable digest" do
assert Ratelimit.route_key(:post, "/webhooks/111/abctoken") == digest =
"POST /webhooks/111/abctoken" :crypto.hash(:sha256, "abctoken")
|> Base.url_encode64(padding: false)
|> binary_part(0, 12)
assert Ratelimit.route_key(:patch, "/webhooks/111/abctoken/messages/@original") == post = Ratelimit.route_key(:post, "/webhooks/111/abctoken")
"PATCH /webhooks/111/abctoken/messages/@original" patch = Ratelimit.route_key(:patch, "/webhooks/111/abctoken/messages/@original")
# interaction id is minor; the token stays literal. assert post == "POST /webhooks/111/#{digest}"
assert Ratelimit.route_key(:post, "/interactions/222/tok/callback") == assert patch == "PATCH /webhooks/111/#{digest}/messages/@original"
"POST /interactions/:id/tok/callback"
# The raw secret must never appear literally in a public route key.
refute post =~ "abctoken"
refute patch =~ "abctoken"
# interaction id is minor (collapses to :id), but the secret token becomes a
# per-interaction digest so one interaction's 429 can't stall the rest.
tok_digest =
:crypto.hash(:sha256, "tok")
|> Base.url_encode64(padding: false)
|> binary_part(0, 12)
interaction = Ratelimit.route_key(:post, "/interactions/222/tok/callback")
assert interaction == "POST /interactions/:id/#{tok_digest}/callback"
# The raw secret token must never appear literally in the route key.
refute interaction =~ "tok"
end
test "different interaction tokens get different (digest-separated) route keys" do
a = Ratelimit.route_key(:post, "/interactions/1/token-aaa/callback")
b = Ratelimit.route_key(:post, "/interactions/1/token-bbb/callback")
# Per-interaction separation: distinct tokens => distinct buckets, so a
# stalled ack on one interaction doesn't gate every other interaction.
refute a == b
refute a =~ "token-aaa"
refute b =~ "token-bbb"
# The interaction id still collapses; only the token digest distinguishes.
assert String.starts_with?(a, "POST /interactions/:id/")
assert String.ends_with?(a, "/callback")
assert String.starts_with?(b, "POST /interactions/:id/")
assert String.ends_with?(b, "/callback")
end end
test "query strings are ignored" do test "query strings are ignored" do
@ -63,10 +97,10 @@ defmodule Dexcord.Api.RatelimitTest do
describe "bucket + global math (injected clock)" do describe "bucket + global math (injected clock)" do
setup do setup do
Dexcord.EnvSandbox.sandbox_env()
{:ok, clock} = Agent.start_link(fn -> 0 end) {:ok, clock} = Agent.start_link(fn -> 0 end)
Application.put_env(:dexcord, :ratelimit_now_fn, fn -> Agent.get(clock, & &1) end) Application.put_env(:dexcord, :ratelimit_now_fn, fn -> Agent.get(clock, & &1) end)
start_supervised!(Ratelimit) start_supervised!(Ratelimit)
on_exit(fn -> Application.delete_env(:dexcord, :ratelimit_now_fn) end)
%{clock: clock} %{clock: clock}
end end
@ -135,5 +169,150 @@ defmodule Dexcord.Api.RatelimitTest do
assert {:wait, ms2} = Ratelimit.acquire(route) assert {:wait, ms2} = Ratelimit.acquire(route)
assert ms2 in 100..102 assert ms2 in 100..102
end end
test "a reset window admits only ~limit callers, not a thundering herd",
%{clock: clock} do
set_clock(clock, 0)
route = "POST /herd"
# limit is 10 (from headers/3); the window resets at t=5000.
:ok = Ratelimit.update(route, headers("hb", 0, "5.0"))
set_clock(clock, 5_000)
results = for _ <- 1..15, do: Ratelimit.acquire(route)
# The reset consumes one token and hands out limit-1 more before waits
# begin: exactly `limit` (10) callers pass, then the rest must wait.
assert Enum.count(results, &(&1 == :ok)) == 10
assert {:wait, _} = List.last(results)
end
test "the idle-TTL sweep evicts stale buckets and their route mappings",
%{clock: clock} do
Application.put_env(:dexcord, :ratelimit_idle_ttl_ms, 1_000)
set_clock(clock, 0)
route = "GET /stale"
# reset_at = 0 + 2000 = 2000.
:ok = Ratelimit.update(route, headers("sb", 5, "2.0"))
# Not yet idle past the TTL (now 2500, cutoff 1500 < reset_at 2000): survives.
set_clock(clock, 2_500)
:ok = Ratelimit.sweep()
assert [_] = :ets.lookup(Ratelimit.table(), {:bucket, "sb"})
assert [_] = :ets.lookup(Ratelimit.table(), {:route_bucket, route})
# Idle past reset + TTL (now 3001, cutoff 2001 > reset_at 2000): evicted,
# and the dangling route→bucket mapping goes with it.
set_clock(clock, 3_001)
:ok = Ratelimit.sweep()
assert [] = :ets.lookup(Ratelimit.table(), {:bucket, "sb"})
assert [] = :ets.lookup(Ratelimit.table(), {:route_bucket, route})
end
test "a probe caller's death releases parked waiters instead of wedging the route" do
route = "POST /probe-death"
test = self()
# First caller is elected the probe and holds it open (never settles).
probe =
spawn(fn ->
send(test, {:probe, Ratelimit.acquire(route)})
Process.sleep(:infinity)
end)
assert_receive {:probe, :ok}
# A second caller parks behind the in-flight probe (blocks in acquire).
spawn(fn -> send(test, {:waiter, Ratelimit.acquire(route)}) end)
# Let it register as a parked waiter before we kill the probe.
Process.sleep(20)
refute_received {:waiter, _}
# Killing the probe must release the waiter (via :DOWN) to re-probe and
# complete - not leave it blocked in a GenServer.call forever.
Process.exit(probe, :kill)
assert_receive {:waiter, :ok}, 1_000
end
test "a still-alive caller that times out abandons its probe instead of wedging" do
route = "POST /abandon"
test = self()
# probe_timeout 0 (a spent deadline): the GenServer.call deterministically
# times out on a non-yielding `after 0` while the server still elects this
# caller the probe. The caller stays ALIVE, so :DOWN can never rescue the
# route - only the explicit abandon_probe cast can.
probe =
spawn(fn ->
send(test, {:probe, Ratelimit.acquire(route, 0)})
Process.sleep(:infinity)
end)
assert_receive {:probe, {:error, :timeout}}
# A fresh caller on the same route must acquire promptly (route not wedged).
spawn(fn -> send(test, {:next, Ratelimit.acquire(route)}) end)
assert_receive {:next, :ok}, 500
assert Process.alive?(probe), "the probe abandoned while still alive; :DOWN must not be why"
Process.exit(probe, :kill)
end
test "a stale abandon for a different pid does not clobber the current probe" do
route = "POST /no-clobber"
test = self()
# A healthy probe holds the route open (never settles yet).
probe =
spawn(fn ->
send(test, {:probe, Ratelimit.acquire(route)})
Process.sleep(:infinity)
end)
assert_receive {:probe, :ok}
# A waiter parks behind the healthy probe.
spawn(fn -> send(test, {:waiter, Ratelimit.acquire(route)}) end)
Process.sleep(20)
refute_received {:waiter, _}
# A stale abandon from an UNRELATED pid (the test process, not the elected
# probe) must be ignored - the pid comparison guards against clobbering a
# different, in-flight probe. Flush the cast with a sync call (FIFO).
Ratelimit.abandon_probe(route)
:ok = Ratelimit.sweep()
refute_received {:waiter, _}
# The real probe dying still releases the waiter, proving it was never freed.
Process.exit(probe, :kill)
assert_receive {:waiter, :ok}, 1_000
end
test "the server-side force-settle timer relinquishes a silently wedged probe" do
# `sandbox_env/0` in setup already restores this introduced key on exit.
Application.put_env(:dexcord, :ratelimit_probe_force_settle_ms, 100)
route = "POST /wedged"
test = self()
# A probe that stays alive and neither settles nor abandons (a lost abandon
# cast): only the belt-and-braces force-settle timer can free the route.
probe =
spawn(fn ->
send(test, {:probe, Ratelimit.acquire(route)})
Process.sleep(:infinity)
end)
assert_receive {:probe, :ok}
spawn(fn -> send(test, {:waiter, Ratelimit.acquire(route)}) end)
Process.sleep(20)
refute_received {:waiter, _}
# Released strictly by the ~100ms force-settle timer, not by :DOWN.
assert_receive {:waiter, :ok}, 1_000
assert Process.alive?(probe)
Process.exit(probe, :kill)
end
end end
end end

View file

@ -0,0 +1,118 @@
defmodule Dexcord.RegistrarIntegrationTest do
@moduledoc """
Drives `Dexcord.Slash.Registrar.run/1` against the scripted `Dexcord.FakeRest`
server to assert the guild vs global routing, the never-wipe-globals rule, and
the cached application id.
"""
use ExUnit.Case, async: false
import ExUnit.CaptureLog
alias Dexcord.Api.Ratelimit
alias Dexcord.FakeRest
alias Dexcord.Slash.Registrar
@token "test.token.value"
defmodule Commands do
use Dexcord.Slash
def commands, do: [%{name: "ping", description: "Pong!"}]
def handle_interaction(_name, _itx), do: :ok
end
defmodule EmptyCommands do
use Dexcord.Slash
def commands, do: []
def handle_interaction(_name, _itx), do: :ok
end
setup do
Dexcord.EnvSandbox.sandbox_env()
Dexcord.Config.put(%{token: @token, handler: nil, intents: 0})
start_supervised!({Finch, name: Dexcord.Finch})
start_supervised!(Ratelimit)
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
FakeRest.stub(
:get,
"/oauth2/applications/@me",
FakeRest.resp(200, body: ~s({"id":"app-99"}))
)
:ok
end
test "global mode overwrites application commands and caches the app id" do
FakeRest.stub(:put, "/applications/app-99/commands", FakeRest.resp(200, body: "[]"))
config = %{slash: Commands, slash_guild_ids: nil}
assert Registrar.run(config) == :ok
assert Dexcord.Config.application_id() == "app-99"
assert_receive {:rest_hit, %{method: "GET", path: "/oauth2/applications/@me"}}
assert_receive {:rest_hit,
%{method: "PUT", path: "/applications/app-99/commands", body: body}}
assert JSON.decode!(body) == [%{"name" => "ping", "description" => "Pong!"}]
end
test "guild mode overwrites each guild and NEVER wipes global commands" do
FakeRest.stub(:put, "/applications/app-99/guilds/g1/commands", FakeRest.resp(200, body: "[]"))
FakeRest.stub(:put, "/applications/app-99/guilds/g2/commands", FakeRest.resp(200, body: "[]"))
config = %{slash: Commands, slash_guild_ids: ["g1", "g2"]}
assert Registrar.run(config) == :ok
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/guilds/g1/commands"}}
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/guilds/g2/commands"}}
# The global-commands route must never be hit in guild (dev) mode.
refute_received {:rest_hit, %{path: "/applications/app-99/commands"}}
end
test "empty commands in global mode SKIPS the overwrite (never wipes) and logs a hint" do
config = %{slash: EmptyCommands, slash_guild_ids: nil}
log =
capture_log(fn ->
assert Registrar.run(config) == :ok
end)
assert_receive {:rest_hit, %{method: "GET", path: "/oauth2/applications/@me"}}
# The global-commands overwrite must NOT be attempted for an empty list.
refute_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}}
assert log =~ "commands/0 is empty in global mode"
assert log =~ "bulk_overwrite_global_commands"
end
test "a repeatedly-failing overwrite retries N times then gives up gracefully (no abnormal exit)" do
# Fast, deterministic retries: 2 sleeps of 0ms => 3 total attempts.
Dexcord.EnvSandbox.sandbox_env()
Application.put_env(:dexcord, :registrar_retry_delays, [0, 0])
FakeRest.stub(:put, "/applications/app-99/commands", FakeRest.resp(403, body: "forbidden"))
config = %{slash: Commands, slash_guild_ids: nil}
log =
capture_log(fn ->
# run/1 returns :ok (exits :normal in the Task) - it never raises/exits abnormally.
assert Registrar.run(config) == :ok
end)
# Exactly 3 overwrite attempts hit the fake, and not a 4th.
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}}
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}}
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}}
refute_receive {:rest_hit, %{method: "PUT", path: "/applications/app-99/commands"}}
assert log =~ "giving up after 3 attempt(s)"
end
end

View file

@ -0,0 +1,83 @@
defmodule Dexcord.RegistrarTreeTest do
@moduledoc """
Verifies the flagship reliability property of finding 1: a persistently-failing
slash registration must NEVER cycle the gateway or terminate the tree. The
Registrar is `restart: :temporary` and owns a bounded in-process retry loop, so
when it gives up it exits `:normal` and everything else - supervisor, gateway,
session - keeps running untouched.
"""
use ExUnit.Case, async: false
alias Dexcord.FakeRest
@token "fake.token.value"
defmodule Handler do
use Dexcord.Handler
end
defmodule Commands do
use Dexcord.Slash
def commands, do: [%{name: "ping", description: "Pong!"}]
def handle_interaction(_name, _itx), do: :ok
end
# Grab a port, then release it so connecting to it is refused (gateway backs off).
defp closed_port do
{:ok, socket} = :gen_tcp.listen(0, [])
{:ok, port} = :inet.port(socket)
:ok = :gen_tcp.close(socket)
port
end
setup do
Dexcord.EnvSandbox.sandbox_env()
# Fast, deterministic registrar retries: 2 sleeps of 0ms => 3 total attempts.
Application.put_env(:dexcord, :registrar_retry_delays, [0, 0])
Application.put_env(:dexcord, :backoff, {50, 500})
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
FakeRest.stub(:get, "/oauth2/applications/@me", FakeRest.resp(200, body: ~s({"id":"app-1"})))
# Registration fails forever with a 4xx.
FakeRest.stub(:put, "/applications/app-1/commands", FakeRest.resp(403, body: "forbidden"))
:ok
end
test "a persistently-failing Registrar gives up without cycling the gateway or tree" do
port = closed_port()
sup =
start_supervised!(
{Dexcord,
token: @token,
handler: Handler,
intents: :default,
slash: Commands,
gateway_url: "ws://127.0.0.1:#{port}"}
)
# Exactly 3 overwrite attempts hit the fake (2 retries), then the Registrar
# gives up - no 4th attempt.
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 2_000
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 2_000
assert_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 2_000
refute_receive {:rest_hit, %{method: "PUT", path: "/applications/app-1/commands"}}, 500
# The whole tree is unharmed: supervisor and gateway are alive.
assert Process.alive?(sup)
children = Supervisor.which_children(Dexcord.Supervisor)
gateway = Enum.find(children, fn {id, _pid, _t, _m} -> id == Dexcord.Gateway end)
assert {Dexcord.Gateway, gw_pid, _t, _m} = gateway
assert is_pid(gw_pid) and Process.alive?(gw_pid)
# The Registrar exited :normal and, being :temporary, was not restarted.
registrar = Enum.find(children, fn {id, _pid, _t, _m} -> id == Dexcord.Slash.Registrar end)
assert registrar == nil or match?({_, :undefined, _, _}, registrar)
end
end

View file

@ -47,6 +47,22 @@ defmodule Dexcord.SessionTest do
refute Session.resumable?() refute Session.resumable?()
end end
test "establish/2 resets last_seq so a new session never inherits the old sequence" do
# Simulate a live prior session with an advanced sequence number.
Session.bump_seq(4242)
assert Session.last_seq() == 4242
# Establishing a fresh session (new READY) must wipe the stale seq; the new
# session's own first frame reseeds it.
Session.establish("new-sess", "wss://resume.example")
assert Session.last_seq() == nil
assert Session.session_id() == "new-sess"
# And a subsequent bump seeds the fresh session cleanly.
Session.bump_seq(1)
assert Session.last_seq() == 1
end
test "mark_fatal records a fatal tuple" do test "mark_fatal records a fatal tuple" do
Session.mark_fatal(4014) Session.mark_fatal(4014)
assert Session.fatal() == {:fatal_close, 4014} assert Session.fatal() == {:fatal_close, 4014}
@ -78,8 +94,11 @@ defmodule Dexcord.SessionTest do
# the Session GenServer (not the writer), the value must persist. # the Session GenServer (not the writer), the value must persist.
task = task =
Task.async(fn -> Task.async(fn ->
Session.bump_seq(777) # establish/2 resets last_seq, so bump AFTER establishing (a fresh session's
# seq is seeded by its own frames). This test only cares that state written by
# a now-dead process persists via the Session-owned ETS table.
Session.establish("s1", "wss://x") Session.establish("s1", "wss://x")
Session.bump_seq(777)
end) end)
Task.await(task) Task.await(task)

223
test/dexcord/slash_test.exs Normal file
View file

@ -0,0 +1,223 @@
defmodule Dexcord.SlashTest do
use ExUnit.Case, async: false
import ExUnit.CaptureLog
alias Dexcord.Api.Ratelimit
alias Dexcord.FakeRest
alias Dexcord.Slash
@token "test.token.value"
defmodule Commands do
use Dexcord.Slash
def commands do
[
%{name: "ping", description: "Pong!"},
%{name: "echo", description: "Echo", options: [%{type: 3, name: "text"}]}
]
end
def handle_interaction("ping", itx) do
send(self(), {:handled, "ping", itx})
:ok
end
def handle_component("refresh", itx) do
send(self(), {:component, "refresh", itx})
:ok
end
def handle_modal("feedback", itx) do
send(self(), {:modal, "feedback", itx})
:ok
end
end
# A module compiled defining ONLY handle_interaction/2 - it must still compile
# (the component/modal callbacks are optional) and route those via the injected
# defaults without crashing.
defmodule LegacyCommands do
use Dexcord.Slash
def commands, do: [%{name: "ping", description: "Pong!"}]
def handle_interaction("ping", itx), do: send(self(), {:legacy, itx})
end
describe "behaviour + use" do
test "commands/0 returns the definition maps" do
assert [%{name: "ping"}, %{name: "echo", options: _}] = Commands.commands()
end
test "dispatch/2 routes a type-2 interaction to handle_interaction/2 by name" do
itx = %{"type" => 2, "data" => %{"name" => "ping"}, "id" => "1"}
assert Slash.dispatch(itx, Commands) == :ok
assert_received {:handled, "ping", ^itx}
end
test "dispatch/2 routes a type-3 (component) interaction to handle_component/2 by custom_id" do
itx = %{"type" => 3, "data" => %{"custom_id" => "refresh"}, "id" => "2"}
assert Slash.dispatch(itx, Commands) == :ok
assert_received {:component, "refresh", ^itx}
end
test "dispatch/2 routes a type-5 (modal) interaction to handle_modal/2 by custom_id" do
itx = %{"type" => 5, "data" => %{"custom_id" => "feedback"}, "id" => "3"}
assert Slash.dispatch(itx, Commands) == :ok
assert_received {:modal, "feedback", ^itx}
end
test "the injected catch-all logs a warning for an unhandled command name" do
itx = %{"type" => 2, "data" => %{"name" => "unknown"}}
log =
capture_log(fn ->
assert Slash.dispatch(itx, Commands) == :ignore
end)
assert log =~ "no handle_interaction/2 clause"
assert log =~ "\"unknown\""
end
test "the injected component/modal catch-alls log at debug, not warning" do
component = %{"type" => 3, "data" => %{"custom_id" => "nope"}}
modal = %{"type" => 5, "data" => %{"custom_id" => "nope"}}
log =
capture_log([level: :debug], fn ->
assert Slash.dispatch(component, Commands) == :ignore
assert Slash.dispatch(modal, Commands) == :ignore
end)
assert log =~ "no handle_component/2 clause"
assert log =~ "no handle_modal/2 clause"
refute log =~ "[warning]"
end
test "a module defining only handle_interaction/2 still routes components/modals via defaults" do
itx2 = %{"type" => 2, "data" => %{"name" => "ping"}}
Slash.dispatch(itx2, LegacyCommands)
assert_received {:legacy, ^itx2}
component = %{"type" => 3, "data" => %{"custom_id" => "x"}}
modal = %{"type" => 5, "data" => %{"custom_id" => "y"}}
capture_log([level: :debug], fn ->
assert Slash.dispatch(component, LegacyCommands) == :ignore
assert Slash.dispatch(modal, LegacyCommands) == :ignore
end)
end
end
describe "response helpers (payload shape via FakeRest)" do
setup do
Dexcord.EnvSandbox.sandbox_env()
Dexcord.Config.put(%{token: @token, handler: nil, intents: 0})
start_supervised!({Finch, name: Dexcord.Finch})
start_supervised!(Ratelimit)
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
:ok
end
@itx %{
"id" => "int-id",
"token" => "int-token",
"application_id" => "app-id",
"data" => %{"name" => "ping"}
}
test "respond/2 with a string sends a type-4 content response to the callback route" do
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
assert {:ok, nil} = Slash.respond(@itx, "pong")
assert_receive {:rest_hit, info}
assert info.method == "POST"
assert info.path == "/interactions/int-id/int-token/callback"
assert JSON.decode!(info.body) == %{"type" => 4, "data" => %{"content" => "pong"}}
end
test "respond/2 with a map maps ephemeral: true to flags 64 and passes embeds through" do
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
assert {:ok, nil} =
Slash.respond(@itx, %{content: "secret", embeds: [%{title: "x"}], ephemeral: true})
assert_receive {:rest_hit, info}
body = JSON.decode!(info.body)
assert body["type"] == 4
assert body["data"]["content"] == "secret"
assert body["data"]["flags"] == 64
assert body["data"]["embeds"] == [%{"title" => "x"}]
end
test "respond/2 accepts string-keyed data maps too" do
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
assert {:ok, nil} = Slash.respond(@itx, %{"content" => "hi", "ephemeral" => true})
assert_receive {:rest_hit, info}
body = JSON.decode!(info.body)
assert body["data"] == %{"content" => "hi", "flags" => 64}
end
test "respond/2 passes a caller-supplied integer flags through unchanged" do
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
assert {:ok, nil} = Slash.respond(@itx, %{content: "hi", flags: 4})
assert_receive {:rest_hit, info}
body = JSON.decode!(info.body)
assert body["data"]["flags"] == 4
end
test "respond/2 OR-s a caller flags with the ephemeral bit (4 + 64 = 68)" do
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
assert {:ok, nil} = Slash.respond(@itx, %{content: "hi", flags: 4, ephemeral: true})
assert_receive {:rest_hit, info}
body = JSON.decode!(info.body)
assert body["data"]["flags"] == 68
end
test "respond_later/1 sends a bare type-5 deferred response" do
FakeRest.stub(:post, "/interactions/int-id/int-token/callback", FakeRest.resp(204))
assert {:ok, nil} = Slash.respond_later(@itx)
assert_receive {:rest_hit, info}
assert JSON.decode!(info.body) == %{"type" => 5}
end
test "followup/2 posts to the application webhook route" do
FakeRest.stub(:post, "/webhooks/app-id/int-token", FakeRest.resp(200, body: ~s({"id":"9"})))
assert {:ok, %{"id" => "9"}} = Slash.followup(@itx, "more")
assert_receive {:rest_hit, info}
assert info.method == "POST"
assert info.path == "/webhooks/app-id/int-token"
assert JSON.decode!(info.body) == %{"content" => "more"}
end
test "edit_response/2 patches the original message route" do
FakeRest.stub(
:patch,
"/webhooks/app-id/int-token/messages/@original",
FakeRest.resp(200, body: ~s({"id":"9"}))
)
assert {:ok, %{"id" => "9"}} = Slash.edit_response(@itx, %{content: "edited"})
assert_receive {:rest_hit, info}
assert info.method == "PATCH"
assert info.path == "/webhooks/app-id/int-token/messages/@original"
assert JSON.decode!(info.body) == %{"content" => "edited"}
end
end
end

View file

@ -34,12 +34,20 @@ defmodule Dexcord.SupervisorSmokeTest do
ids = Enum.map(children, fn {id, _pid, _type, _mods} -> id end) ids = Enum.map(children, fn {id, _pid, _type, _mods} -> id end)
assert Dexcord.Session in ids assert Dexcord.Session in ids
assert Dexcord.Dispatcher in ids # Cache + Dispatcher now live under a nested :rest_for_one supervisor.
assert Dexcord.Cache.Supervisor in ids
assert Dexcord.Gateway in ids assert Dexcord.Gateway in ids
# Every child is a live process (gateway is retrying, not crashed). # Every top-level child is a live process (gateway is retrying, not crashed).
for {_id, child_pid, _type, _mods} <- children do for {_id, child_pid, _type, _mods} <- children do
assert is_pid(child_pid) and Process.alive?(child_pid) assert is_pid(child_pid) and Process.alive?(child_pid)
end end
# The nested supervisor really does hold Cache and the Dispatcher.
{_, nested_pid, _, _} = Enum.find(children, &(elem(&1, 0) == Dexcord.Cache.Supervisor))
nested_ids = Enum.map(Supervisor.which_children(nested_pid), &elem(&1, 0))
assert Dexcord.Cache in nested_ids
assert Dexcord.Dispatcher in nested_ids
end end
end end

View file

@ -0,0 +1,27 @@
defmodule Dexcord.FakeGateway.CacheProbeHandler do
@moduledoc false
# A `Dexcord.Handler` used to prove ordering: the Dispatcher writes the cache
# INLINE before spawning the handler Task, so by the time this handler runs the
# cache must already reflect the event. For each event it reads the relevant
# cache entry and relays what it observed to the subscribed test process as
# `{:handler_saw, name, cache_result}`.
@behaviour Dexcord.Handler
@key {__MODULE__, :sink}
def subscribe(pid), do: :persistent_term.put(@key, pid)
@impl true
def handle_event({name, data}) do
case :persistent_term.get(@key, nil) do
nil -> :ok
pid -> send(pid, {:handler_saw, name, probe(name, data)})
end
end
defp probe(:GUILD_CREATE, %{"id" => id}), do: Dexcord.Cache.guild(id)
defp probe(:MESSAGE_CREATE, %{"author" => %{"id" => uid}}), do: Dexcord.Cache.user(uid)
defp probe(:GUILD_MEMBERS_CHUNK, %{"guild_id" => gid}), do: Dexcord.Cache.members(gid)
defp probe(_name, _data), do: :na
end

View file

@ -0,0 +1,41 @@
defmodule Dexcord.EnvSandbox do
@moduledoc false
# A single, bulletproof application-env fixture for the test suite.
#
# Many tests tune `:dexcord` application env (timing knobs, budgets, rate-limit
# clocks, base URLs, ...) to exercise real logic quickly. Any key a test forgets
# to restore leaks into whatever test runs next and produces order-dependent,
# seed-sensitive failures. Rather than have every file maintain its own hand-rolled
# save/restore list (and get it subtly wrong), tests call `sandbox_env/0` once in
# `setup`: it snapshots the ENTIRE `:dexcord` env up front and, via `on_exit`,
# restores it wholesale afterwards - deleting any key the test introduced and
# resetting every pre-existing key to its captured value. Tests then `put_env`
# freely with no per-key bookkeeping.
import ExUnit.Callbacks, only: [on_exit: 1]
@doc """
Snapshots all `:dexcord` application env and registers wholesale restoration on
test exit. Call once from a test's `setup`.
"""
@spec sandbox_env :: :ok
def sandbox_env do
before = Application.get_all_env(:dexcord)
before_keys = MapSet.new(before, fn {k, _v} -> k end)
on_exit(fn ->
# Drop any key the test introduced that wasn't there before.
for {key, _val} <- Application.get_all_env(:dexcord),
not MapSet.member?(before_keys, key) do
Application.delete_env(:dexcord, key)
end
# Restore every snapshotted key to exactly its captured value.
for {key, val} <- before do
Application.put_env(:dexcord, key, val)
end
end)
:ok
end
end